Adding a New Property: Step-by-Step#
This guide explains how to add new properties to StructureData and StructureBuilder.
This guide covers two types of properties:
Site properties - Properties that vary per atom (e.g.,
new_site_property)Global properties - Properties that apply to the entire structure (e.g.,
new_global_property)
Before You Start: Contributing to aiida-atomistic#
If you want to add a new property to the official aiida-atomistic package:
1. Fork the Repository#
Go to the aiida-atomistic GitHub repository and click the “Fork” button to create your own copy.
2. Clone Your Fork#
git clone https://github.com/YOUR-USERNAME/aiida-atomistic.git
cd aiida-atomistic
3. Create a Feature Branch#
git checkout -b add-new-property
4. Install in Development Mode#
pip install -e .[dev]
Now you can make changes and they’ll be immediately reflected in your Python environment.
5. After Making Changes#
Once you’ve completed the steps below and added tests:
# Run tests
pytest tests/
# Commit your changes
git add .
git commit -m "Add new_site_property to Site model"
# Push to your fork
git push origin add-new-property
6. Create a Pull Request#
Go to your fork on GitHub and click “New Pull Request”. Provide a clear description of:
What property you’re adding
Why it’s useful
Any relevant scientific context
Link to related issues (if any)
Tip
Before creating a PR, it’s a good idea to open an issue on the main repository to discuss whether the property should be added. The maintainers can provide guidance on the best implementation approach.
Adding a Site Property#
Let’s add a hypothetical new_site_property (a scalar value per site, like an effective nuclear charge or local electric field strength).
Step 1: Add Field to the Site Model#
Edit src/aiida_atomistic/data/structure/site.py:
class Site(BaseModel):
# ... existing fields ...
new_site_property: t.Optional[float] = Field(
default=None,
json_schema_extra={"tolerance": 1e-4},
description="Description of the new site property"
)
Tip
Add json_schema_extra={"tolerance": value} to define the default tolerance for kind classification. This tolerance will be used when comparing sites to determine if they belong to the same kind.
Step 2: Add to Site Property Dictionary#
Edit src/aiida_atomistic/data/structure/__init__.py:
_SITE_PROPERTIES = [
# ... existing properties ...
'new_site_property', # Add here
]
# Add default value if needed
_DEFAULT_VALUES = {
# ... existing defaults ...
'new_site_property': 0.0,
}
Step 3: Add Computed Field (for array access)#
If the property should be accessible as an array, add a computed field in models.py:
@computed_field
@property
def new_site_property(self) -> t.Optional[np.ndarray]:
"""
Return the new_site_property values of all sites as a numpy array.
Returns:
np.ndarray: An array of values corresponding to each site, or None if not set.
"""
if all(site.new_site_property is None for site in self.sites):
return None
return np.array([
site.new_site_property if site.new_site_property is not None
else _DEFAULT_VALUES['new_site_property']
for site in self.sites
])
Note
The computed field name is typically the plural form of the site property name (e.g., new_site_property → new_site_property). This allows accessing all values at once as an array.
Step 4: Add Setter Method#
Add a setter method in src/aiida_atomistic/data/structure/setter_mixin.py:
def set_new_site_property(self, values: t.Union[list, np.ndarray]):
"""
Set the new_site_property values for all sites.
Args:
values: Array of values, one per site.
Raises:
ValueError: If length doesn't match number of sites.
TypeError: If called on an immutable structure.
"""
if not isinstance(self, StructureBuilder):
raise TypeError("Can only set properties on StructureBuilder")
values = np.asarray(values)
if len(values) != len(self.properties.sites):
raise ValueError(
f"Length of values ({len(values)}) must match "
f"number of sites ({len(self.properties.sites)})"
)
for i, value in enumerate(values):
self.properties.sites[i].new_site_property = float(value)
Step 5: Add Getter Method#
Add a getter method in src/aiida_atomistic/data/structure/getter_mixin.py:
def get_new_site_property(self) -> t.Optional[np.ndarray]:
"""
Get the new_site_property values for all sites.
Returns:
np.ndarray: Array of values, or None if not set.
"""
return self.properties.new_site_property
Step 6: Add Remove Method#
Add a remove method in src/aiida_atomistic/data/structure/setter_mixin.py:
def remove_new_site_property(self):
"""
Remove new_site_property from all sites.
"""
self.remove_property('new_site_property')
return
Important
Why Remove Methods Are Important:
Allow users to clean up properties they no longer need
Help create different variations of structures for testing
Essential for structure manipulation workflows
Should be documented alongside setters in guides
Step 7: Add Validation (optional)#
Add validation in the Site model if needed:
@field_validator('new_site_property')
@classmethod
def validate_new_site_property(cls, v):
"""Validate new_site_property value."""
if v is not None and (v < -10.0 or v > 10.0):
raise ValueError(f"new_site_property must be between -10 and 10, got {v}")
return v
Step 7: Add Tests#
Create tests in tests/data/test_models.py or appropriate test file:
def test_new_site_property():
"""Test new_site_property."""
structure_dict = {
"pbc": [True, True, True],
"cell": [[3.0, 0, 0], [0, 3.0, 0], [0, 0, 3.0]],
"sites": [
{"symbol": "Fe", "position": [0, 0, 0], "new_site_property": 1.5},
{"symbol": "Fe", "position": [1.5, 1.5, 1.5], "new_site_property": 2.3},
],
}
structure = StructureData(**structure_dict)
assert np.allclose(structure.properties.new_site_property, [1.5, 2.3])
# Test setter for mutable
mutable = StructureBuilder(**structure_dict)
mutable.set_new_site_property([3.1, 4.2])
assert np.allclose(mutable.get_new_site_property(), [3.1, 4.2])
Step 8: Update Documentation#
Add the property to the documentation:
Add to property tables in guides
Add usage examples
Document any special behavior or constraints
Adding a Global Property#
Let’s add a hypothetical new_global_property (a property that applies to the entire structure, like temperature or pressure).
Step 1: Add Field to the Structure Model#
Edit src/aiida_atomistic/data/structure/models.py:
class StructureBaseModel(BaseModel):
# ... existing fields ...
new_global_property: t.Optional[float] = Field(
default=None,
description="Description of the new global property (e.g., temperature in K)"
)
Step 2: Add to Global Property Dictionary#
Edit src/aiida_atomistic/data/structure/__init__.py:
_GLOBAL_PROPERTIES = [
# ... existing properties ...
'new_global_property', # Add here
]
# Add default value if needed
_DEFAULT_VALUES = {
# ... existing defaults ...
'new_global_property': 300.0, # e.g., room temperature
}
Step 3: Add Setter Method (if needed)#
For mutable structures, add a setter method in setter_mixin.py:
def set_new_global_property(self, value: float):
"""
Set the new_global_property.
Args:
value: The new value for the global property.
Raises:
TypeError: If called on an immutable structure.
"""
if not isinstance(self, StructureBuilder):
raise TypeError("Can only set properties on StructureBuilder")
self.properties.new_global_property = float(value)
Step 4: Add Getter Method (if needed)#
Add a getter method in getter_mixin.py:
def get_new_global_property(self) -> t.Optional[float]:
"""
Get the new_global_property value.
Returns:
float: The global property value, or None if not set.
"""
return self.properties.new_global_property
Step 5: Add Remove Method (if needed)#
Add a remove method in setter_mixin.py:
def remove_new_global_property(self):
"""
Remove the new_global_property.
"""
self.remove_property('new_global_property')
return
Step 6: Add Validation (optional)#
Add validation in StructureBaseModel if needed:
@field_validator('new_global_property')
@classmethod
def validate_new_global_property(cls, v):
"""Validate new_global_property value."""
if v is not None and v < 0:
raise ValueError(f"new_global_property must be non-negative, got {v}")
return v
Step 7: Add Tests#
Create tests in tests/data/test_models.py:
def test_new_global_property():
"""Test new_global_property."""
structure_dict = {
"pbc": [True, True, True],
"cell": [[3.0, 0, 0], [0, 3.0, 0], [0, 0, 3.0]],
"sites": [{"symbol": "Fe", "position": [0, 0, 0]}],
"new_global_property": 500.0,
}
structure = StructureData(**structure_dict)
assert structure.properties.new_global_property == 500.0
# Test setter for mutable
mutable = StructureBuilder(**structure_dict)
mutable.set_new_global_property(600.0)
assert mutable.get_new_global_property() == 600.0
# Test remove method
mutable.remove_new_global_property()
assert mutable.get_new_global_property() is None
Step 8: Update Documentation#
Document the new global property with usage examples.
Complete Checklist#
When adding a new site property, ensure you:
[ ] Add field to
Sitemodel insite.py[ ] Add
json_schema_extra={"tolerance": value}for kind classification[ ] Add to
_SITE_PROPERTIESlist in__init__.py[ ] Add default value (if applicable) in
_DEFAULT_VALUES[ ] Add computed field (for array access) in
models.py[ ] Add setter method in
setter_mixin.py[ ] Add getter method in
getter_mixin.py[ ] Add remove method in
setter_mixin.py[ ] Add validation (if needed) in model validators
[ ] Add comprehensive tests (including remove functionality)
[ ] Update documentation with examples
[ ] Add to property reference tables
When adding a new global property, ensure you:
[ ] Add field to
StructureBaseModelinmodels.py[ ] Add to
_GLOBAL_PROPERTIESlist in__init__.py[ ] Add default value (if applicable) in
_DEFAULT_VALUES[ ] Add setter method (if needed) in
setter_mixin.py[ ] Add getter method (if needed) in
getter_mixin.py[ ] Add remove method (if needed) in
setter_mixin.py[ ] Add validation (if needed) in model validators
[ ] Add comprehensive tests (including remove functionality)
[ ] Update documentation with examples
Common Pitfalls#
❌ Direct Modification in Mutable Structures#
# ❌ BAD: Bypasses validation and can cause inconsistencies
mutable_structure.properties.sites[0].new_site_property = 1.5
# ✅ GOOD: Uses setter method with validation
mutable_structure.set_new_site_property([1.5, 2.3])
❌ Forgetting to Update Property Dictionaries#
# If you add a field but forget to add it to _SITE_PROPERTIES or _GLOBAL_PROPERTIES:
# - It won't be included in to_dict()
# - It won't be stored properly
# - Kind-based compression will ignore it (for site properties)
❌ Missing Computed Field for Site Properties#
# Without computed field:
structure.properties.sites[0].new_site_property # ✅ Works
structure.properties.new_site_property # ❌ AttributeError
# With computed field:
structure.properties.new_site_property # ✅ Returns np.array([...])
❌ Forgetting Tolerance for Site Properties#
# Without tolerance in json_schema_extra:
# - Default tolerance (1e-3) will be used for kind classification
# - May not be appropriate for your property's precision
# With tolerance:
new_site_property: t.Optional[float] = Field(
default=None,
json_schema_extra={"tolerance": 1e-4} # ✅ Appropriate for this property
)
Examples#
Example 1: Adding Occupation Numbers (Site Property)#
# 1. Add to Site model in site.py
class Site(BaseModel):
occupation: t.Optional[float] = Field(
default=None,
ge=0,
le=1,
json_schema_extra={"tolerance": 1e-3},
description="Site occupation number (0-1)"
)
# 2. Add to __init__.py
_SITE_PROPERTIES = [..., 'occupation']
_DEFAULT_VALUES = {..., 'occupation': 1.0}
# 3. Add computed field in models.py
@computed_field
@property
def occupations(self) -> t.Optional[np.ndarray]:
if all(site.occupation is None for site in self.sites):
return None
return np.array([
site.occupation if site.occupation is not None else 1.0
for site in self.sites
])
# 4. Add setter in setter_mixin.py
def set_occupations(self, occupations: t.Union[list, np.ndarray]):
if not isinstance(self, StructureBuilder):
raise TypeError("Can only set properties on StructureBuilder")
occupations = np.asarray(occupations)
if len(occupations) != len(self.properties.sites):
raise ValueError(f"Length mismatch: {len(occupations)} vs {len(self.properties.sites)}")
for i, occ in enumerate(occupations):
self.properties.sites[i].occupation = float(occ)
# 5. Add getter in getter_mixin.py
def get_occupations(self) -> t.Optional[np.ndarray]:
return self.properties.occupations
# 6. Use it
structure_dict = {
"pbc": [True, True, True],
"cell": [[3.0, 0, 0], [0, 3.0, 0], [0, 0, 3.0]],
"sites": [
{"symbol": "Fe", "position": [0, 0, 0], "occupation": 0.95},
{"symbol": "Fe", "position": [1.5, 1.5, 1.5], "occupation": 0.90},
]
}
structure = StructureData(**structure_dict)
print(structure.properties.occupations) # [0.95, 0.9]
Example 2: Adding Temperature (Global Property)#
# 1. Add to StructureBaseModel in models.py
class StructureBaseModel(BaseModel):
temperature: t.Optional[float] = Field(
default=None,
ge=0,
description="Temperature in Kelvin"
)
# 2. Add to __init__.py
_GLOBAL_PROPERTIES = [..., 'temperature']
_DEFAULT_VALUES = {..., 'temperature': 300.0}
# 3. Add setter in setter_mixin.py
def set_temperature(self, temperature: float):
if not isinstance(self, StructureBuilder):
raise TypeError("Can only set properties on StructureBuilder")
if temperature < 0:
raise ValueError("Temperature must be non-negative")
self.properties.temperature = float(temperature)
# 4. Add getter in getter_mixin.py
def get_temperature(self) -> t.Optional[float]:
return self.properties.temperature
# 5. Use it
structure_dict = {
"pbc": [True, True, True],
"cell": [[3.0, 0, 0], [0, 3.0, 0], [0, 0, 3.0]],
"sites": [{"symbol": "Fe", "position": [0, 0, 0]}],
"temperature": 500.0,
}
structure = StructureData(**structure_dict)
print(structure.properties.temperature) # 500.0
# For mutable structures
mutable = StructureBuilder(**structure_dict)
mutable.set_temperature(600.0)
print(mutable.get_temperature()) # 600.0
Example 3: Adding Temperature Factor / B-factor (Site Property with Validation)#
# Add as site property with validation
class Site(BaseModel):
b_factor: t.Optional[float] = Field(
default=None,
ge=0,
json_schema_extra={"tolerance": 1e-2},
description="Temperature factor (B-factor) in Ų"
)
@field_validator('b_factor')
@classmethod
def validate_b_factor(cls, v):
if v is not None and v > 100:
warnings.warn(f"Unusually high B-factor: {v} Ų")
return v
# Usage
structure_dict = {
"pbc": [True, True, True],
"cell": [[5.0, 0, 0], [0, 5.0, 0], [0, 0, 5.0]],
"sites": [
{"symbol": "C", "position": [0, 0, 0], "b_factor": 15.2},
{"symbol": "C", "position": [2.5, 2.5, 2.5], "b_factor": 18.7},
]
}
Submitting Your Contribution#
After implementing your new property following all the steps above:
1. Make Sure Everything Works#
# Run the full test suite
pytest tests/
# Run tests for your specific property
pytest tests/data/test_models.py::test_new_site_property -v
# Check code style (if using pre-commit hooks)
pre-commit run --all-files
2. Update Documentation#
Make sure you’ve updated:
[ ] Property tables in
docs/source/in_depth/properties.md[ ] Usage examples in relevant tutorials
[ ] API reference documentation (docstrings)
[ ] Changelog with a brief note about the new property
3. Create a Pull Request#
Your PR description should include:
## Description
Brief description of what property you're adding and why.
## Changes
- Added `new_site_property` to Site model
- Added setter/getter methods
- Added computed field for array access
- Added validation
- Added comprehensive tests
- Updated documentation
## Scientific Context
Explain the scientific use case for this property (e.g., "Used in MD for...").
## Checklist
- [ ] Tests pass
- [ ] Documentation updated
- [ ] Follows existing code style
- [ ] Added to CHANGELOG.md
## Related Issues
Closes #XXX (if applicable)
Tip
For Personal/Plugin-Specific Properties
If your property is very specific to your use case and not generally applicable, consider:
Using the
customdictionary in structures insteadCreating a plugin that extends
aiida-atomisticKeeping it in your own fork for personal use
Only submit properties to the main repository that have broad applicability to the community.