Skip to content

Add CoRE MOF data and table schemas - #2156

Merged
bfoley12 merged 11 commits into
materialsproject:masterfrom
100mingyu-rgb:feat/core-mofs-lux-schema
Nov 6, 2026
Merged

bfoley12 merged 11 commits into
materialsproject:masterfrom
100mingyu-rgb:feat/core-mofs-lux-schema

Conversation

@100mingyu-rgb

@100mingyu-rgb 100mingyu-rgb commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

This PR defines Pydantic schemas for CoRE MOF. Each Contribution represents one CoRE MOF structure.

File Model Content
data.py Data Contribution.data: nine groups with 46 input fields (15 required, 31 optional), including two automatically calculated volume fields
zeo_features.py ZeoFeatures One row of the zeoFeatures Table, with 26 columns
topology.py Topology One row per subnet in the topologySubnets Table, with 10 columns
rac_features.py RacFeatures An optional rac5Features Table with one row and 264 numeric RAC descriptors, using a maximum graph depth of 5

Identifiers, formulas, and structures use native MPContribs components. The data model contains source and reference information, MOFid, crystallographic and metal information, checker results, topology summaries, and MOFSimplify geometric descriptors with density as an additional calculation input.

GeometryData calculates missing vpov and gpov values and validates supplied values against their calculation inputs. Model validators check metal element lists and counts, checker consensus, topology summaries and rows, and OMS availability and count. The exported validate_topology(summary, rows) function checks relationships between the topology summary and its Table.

Field names use camelCase. Optional fields default to None, numeric values must be finite, and ordinary Pydantic type conversion remains enabled. Boolean values are rejected as numeric inputs.

An optional CCDC search URL is restricted to CSD sources and must match the source refcode.

Topology keys and names have bounded lengths; genomes additionally validate a space-separated integer representation.

Release versions follow the v<number>.<number>.<number> format, such as v26.0.2.

@100mingyu-rgb

Copy link
Copy Markdown
Contributor Author

@bfoley12 The CoRE MOF v26.0.2 Lux schema PR is ready for your review. GitHub did not allow the fork author to add a formal review request, so I am tagging you here as requested. The PR is schema-only and includes the design questions in the description.

@100mingyu-rgb
100mingyu-rgb force-pushed the feat/core-mofs-lux-schema branch from c3f184c to b6d52ea Compare September 14, 2026 00:00
@bfoley12
bfoley12 self-requested a review September 14, 2026 16:19

@bfoley12 bfoley12 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In addition to comments in the PR:

  1. Unclear what the "Contribution" is
  2. All field names must be camelCase, not snake_case
  3. Field names should not include units - use client.init_columns to set the units of the data
  4. Lots of Literals that go unused
  5. Possibility for non-final contributions to be submitted

We will go through more rounds of review. There are things that will need to be refactored, but this needs to get to a more final state before that.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not needed - we save structures as a Structure object internally. You can submit structures by using submit_contributions and having structures described in the submitted data.

Note: You will also need to include sites, lattice, and optionally charge fields in addition to cif.

Sites that use CifManifestRecord can reference pymatgen.Structure instead.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this accepts Errors from a pipeline? Only successfully ran pipelines should have final data submitted.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this accepts Errors from a pipeline? Only successfully ran pipelines should have final data submitted.

StructureId = Annotated[
str,
Field(
pattern=r"^(ASR|FSR|ION)-(COD|CSD|SI)-(?:\d{4}|UNKN)-\d{4}$",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplication of _STRUCTURE_ID_RE

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this accepts Errors from a pipeline? Only successfully ran pipelines should have final data submitted.


parsed = parse_structure_id(structure_id)
if cif_file is not None and cif_file != f"cifs/{structure_id}.cif":
raise ValueError("cif_file must equal cifs/{structure_id}.cif")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing f-string

Comment on lines +25 to +30
PositiveSize = Annotated[
int,
Field(gt=0, description="File size in bytes."),
]
NonNegativeInt = Annotated[int, Field(ge=0)]
NonNegativeFloat = Annotated[float, Field(ge=0)]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These exist as pydantic.* - use them directly

Comment on lines +33 to +41
def _parse_dimension(value: object) -> object:
"""Coerce integer CSV cells while retaining a bounded JSON integer type."""

if isinstance(value, str) and value in {"0", "1", "2", "3"}:
return int(value)
return value


Dimension = Annotated[Literal[0, 1, 2, 3], BeforeValidator(_parse_dimension)]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pydantic handles string coercion. Can drop the validator

metal_element_count: NonNegativeInt = Field(
description="Number of distinct detected metal elements."
)
metal_atom_count: NonNegativeFloat = Field(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

count should be Int

@100mingyu-rgb

Copy link
Copy Markdown
Contributor Author

@bfoley12 Thank you for the review. I revised the schema so that one contribution represents one final CoRE MOF structure identified by a unique structureId. The previous artifact-specific schemas were replaced with one minimal CoreMofContribution using StructureType. Fields are camelCase and unit-free, optional fields default to None, and operational or non-final pipeline data were removed. The updated tests pass locally. Could you please review the revised structure?

@bfoley12 bfoley12 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a major change from the last commit. Make sure you are including enough information. The information just has to be a well-structured final datapoint (with some associated data (tables, structures).

Overall good step in the right direction, just make sure you aren't leaving out additional meaningful data.

Comment on lines +30 to +32
structureId: StructureId = Field(
description="Release-preserved identifier for this CoRE MOF structure."
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since these are your unique identifiers, they should not live in this class. when submitting contributions, you should set identifiers to your structureId

Comment on lines +33 to +38
structure: StructureType = Field(
description="Pymatgen Structure containing lattice, sites, and charge."
)
cif: Annotated[str, StringConstraints(min_length=1)] = Field(
description="Original Crystallographic Information File text."
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove these. Every Contribution carries with it a structures field. When submitting you contributions, you can also submit a Structure there.

See this Quickstart example. Except in that example, identifier would be your structureId and data is what we are describing in this model. Then, structures would be a list of pymatgen Structures.

Let me know if a further example would help!

sourceDatabase: SourceDatabase = Field(
description="COD, CSD, or supporting-information provenance category."
)
sourceId: Annotated[str, StringConstraints(min_length=1)] = Field(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you put a max_length on this? It helps prevent abuse. You can make it something like 1.5x the max length of the longest source ID that you have. Even better would be to more strongly enforce the format of the sourceId

Comment on lines +48 to +51
formula: str | None = Field(
default=None,
description="Chemical formula recorded for the release structure."
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will also go in the top level of the Contribution. Here, we are just describing Contribution.data. See the Quickstart link in an earlier comment

Remove the project-specific README and schema test file from this PR.
Keep all six schema and import modules and the package-level README unchanged.
@100mingyu-rgb 100mingyu-rgb changed the title Add CoRE MOF v26.0.2 Lux schemas Add CoRE MOF data and table schemas Oct 13, 2026
@100mingyu-rgb

Copy link
Copy Markdown
Contributor Author

@bfoley12 I have pushed the updated CoRE MOF data and table schemas, incorporating your feedback. Could you please review the latest changes when you have a chance?

Thank you for your guidance.

@bfoley12 bfoley12 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR is in good shape! Huge changes since the start. Few comments regarding some typing.

One overall comment: I'd caution against setting model_config to strict=True, the type conversion is pretty useful, especially with tabular data where spreadsheet software (ie. Excel) might mangle the types. Ie. opening "RAC.csv" in excel with racsFunctioNGroupDFuncI0All=1.0 transforms 1.0 -> 1. Then when saving the csv, Excel will save it as 1, which will then be rejected by StrictFloat.

Hoping we are near the end of the review cycle!

Comment on lines +73 to +78
mofidV1: str | None = Field(
default=None, description="Original MOFid v1 string, when available."
)
mofidV2: str | None = Field(
default=None, description="Original MOFid v2 string, when available."
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can a MOF have both a V1 and V2 id? And can you add a standard format for these?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, a MOF can have both a V1 and a V2 ID. MOFid V2 is generated by post-processing the V1 calculation outputs, incorporating node composition and type information and converting linker representations from SMILES to SELFIES. The two IDs therefore represent the same MOF in different formats, and we retain both when available.

Comment on lines +99 to +101
metalElements: str | None = Field(
default=None, description="Semicolon-separated metal symbols."
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a field_validator that uses pymatgen.core.Element.is_metal() on each element in list to verfy.

Comment on lines +126 to +138
mofClassifierStatus: Literal["PASS", "FAIL"] = Field(
description="Completed MOFClassifier vote."
)
mofCheckerStatus: Literal["PASS", "FAIL"] = Field(
description="Completed MOFChecker vote."
)
chenManzStatus: Literal["PASS", "FAIL"] = Field(
description="Completed Chen-Manz vote."
)
mosaecStatus: Literal["PASS", "FAIL"] = Field(description="Completed MOSAEC vote.")
setcGatStatus: Literal["PASS", "FAIL"] = Field(
description="Completed SETC-GAT vote."
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make Pass/Fail an Enum since it is repeated a lot.

class CheckerStatus(StrEnum):
    PASS = "PASS"
    FAIL = "FAIL"

Comment on lines +139 to +147
consensus3: Literal["CR", "NCR", "AMBIGUOUS"] = Field(
description="Recorded agreement of MOFClassifier, MOFChecker, and Chen-Manz."
)
consensus4: Literal["CR", "NCR", "AMBIGUOUS"] = Field(
description="Recorded agreement of those three checkers and MOSAEC."
)
consensus5: Literal["CR", "NCR", "AMBIGUOUS"] = Field(
description="Recorded agreement of all five checkers."
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to above, make a StrEnum.

from pydantic import BaseModel, ConfigDict, StrictFloat


class RacFeatures(BaseModel):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain the format of this to me? A single contribution has this as a table, each field is a column name in that table, and a contribution has many of these, is that correct?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each Contribution optionally includes one rac5Features Table with exactly one row and 264 columns, one column per descriptor. The columns are features of a single structure, rather than separate records.
These revised autocorrelation (RAC) descriptors encode atomic properties and connectivity for use as machine-learning features. The maximum graph depth is 5, covering bond distances from 0 to 5. If no valid RAC result is available, we omit only the rac5Features Table.

Comment on lines +16 to +18
racsFunctionGroupDFuncI0All: StrictFloat
racsFunctionGroupDFuncI1All: StrictFloat
racsFunctionGroupDFuncI2All: StrictFloat

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For all of the fields in this class, I would suggest making them regular floats. StrictFloat will prevent converting from int to float, and some csv software might convert floats to ints if they end in .0 ie. 1.0 -> 1. I would remove strict=True from model_config for the same reason. Unless there is a specific reason that you made them strict, in which case it is fine to leave it.

class Topology(BaseModel):
"""One subnet from a successful, available parent topology result."""

model_config = ConfigDict(extra="forbid", allow_inf_nan=False, strict=True)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to rac_features (and just generally), I'd only set model_config to strict if you are certain floats or bools will not be entering int fields.

@100mingyu-rgb

Copy link
Copy Markdown
Contributor Author

@bfoley12 Thank you for taking the time to provide feedback despite your busy schedule. I have updated the schemas to address your comments.

@bfoley12 bfoley12 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice. Last change I found here.

Comment on lines +130 to +136
hasOpenMetalSites: bool | None = Field(
default=None,
description="Whether open metal sites were detected; false is a valid successful result.",
)
openMetalSiteCount: NonNegativeInt | None = Field(
default=None, description="Number of detected open metal sites, dimensionless."
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume when hasOpenMetalSites == True that openMetalSiteCount != None? If that is true, then hasOpenMetalSites would be redundant and we don't need to include it.

@100mingyu-rgb 100mingyu-rgb Oct 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I confirmed that the field was redundant and removed it.

@bfoley12 bfoley12 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Saw some inconsistencies in the Readme with the models. Can you add the validators I specified?

Comment on lines +193 to +194
consensus4: ConsensusStatus = Field(
description="Recorded agreement of those three checkers and MOSAEC."

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you be explicit in which checkers (consensus3 + MOSAEC)

Comment on lines +88 to +90
| `geometry.vpov` | VPOV | `poavVolFrac + ponavVolFrac` | dimensionless |
| `geometry.gpov` | GPOV | `vpov / Zeo.density` | cm^3/g |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either add validation for this, or make vpov and gpov properties

Comment on lines +118 to +120
- Topology: include summary details and subnet rows only for a successful,
available parent result. ERROR/PARTIAL parents contribute only
`data.topology.topologyAvailable = False`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add validation on the model


OMS presence is derived as `openMetalSiteCount > 0` when `omsAvailable` is
true. The redundant `hasOpenMetalSites` column is not included. A zero count
means no detected OMS; an unavailable result remains missing, rather than zero.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a validator

@bfoley12 bfoley12 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Saw some inconsistencies in the Readme with the models. Can you add the validators I specified?

@100mingyu-rgb

Copy link
Copy Markdown
Contributor Author

I've addressed your feedback. Thank you!

@bfoley12 bfoley12 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Few more comments for tightening fields

def gpov(self) -> float | None:
if self.vpov is None or self.density is None:
return None
return self.vpov / self.density

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see you made these computed_fields. Since you want to keep them fields (that's fine, it's coming from your data), just add a validator for them that checks that they are close to their calculations. Computed fields with extra=forbid don't deserialize and raise an error (extra=forbid is correct here)

Comment on lines +144 to +150
metalElements: str | None = Field(
default=None,
description=(
"Semicolon-separated element symbols, each classified as a metal "
"by pymatgen."
),
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a reasonable cap on length (you don't have to be strict - I don't want you to have to update this as your experiments grow). Enforce semi-colon separated list format (could use a type so it can be used for metalloidElements too)

Comment on lines +160 to +162
metalloidElements: str | None = Field(
default=None, description="Semicolon-separated metalloid symbols."
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment on metalElements

Comment on lines +339 to +345
dif: float | None = Field(
default=None,
description=(
"MOFSimplify Dif: maximum included-sphere diameter along the "
"free-sphere path, angstrom; source sign policy is handled upstream."
),
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like this is a diameter, can you specify >(=)0?

Comment on lines +31 to +57
singleNodeTopologyKey: str = Field(
min_length=1,
description="Nonblank SingleNodes topology key from the successful source result.",
)
singleNodeTopologyName: str | None = Field(
default=None,
description="Recorded SingleNodes topology name; original empty strings are retained.",
)
singleNodeTopologicalGenome: str | None = Field(
default=None,
description="Recorded SingleNodes topological genome; original empty strings are retained.",
)
allNodeDimension: int = Field(
ge=0, le=3, description="AllNodes network dimension, 0 to 3, dimensionless."
)
allNodeTopologyKey: str = Field(
min_length=1,
description="Nonblank AllNodes topology key from the successful source result.",
)
allNodeTopologyName: str | None = Field(
default=None,
description="Recorded AllNodes topology name; original empty strings are retained.",
)
allNodeTopologicalGenome: str | None = Field(
default=None,
description="Recorded AllNodes topological genome; original empty strings are retained.",
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If possible, specify upper bound of str length. If there is a common pattern, please specify (otherwise, just use the max length) - feel free to be loose with the cap so you don't have to make a PR for each new experiment

@100mingyu-rgb

100mingyu-rgb commented Oct 31, 2026 via email •

Copy link
Copy Markdown
Contributor Author

@bfoley12 bfoley12 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great. Very happy. Last 2 comments I can find.


model_config = ConfigDict(extra="forbid", allow_inf_nan=False)

releaseVersion: str = Field(min_length=1, description="CoRE dataset version.")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have regex for this? If so add

@100mingyu-rgb

100mingyu-rgb commented Nov 3, 2026 via email

Copy link
Copy Markdown
Contributor Author

@bfoley12

bfoley12 commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

@100mingyu-rgb Great thanks! Last question: how large are the topology tables in terms of bytes? <16MB?

@100mingyu-rgb

100mingyu-rgb commented Nov 4, 2026 via email

Copy link
Copy Markdown
Contributor Author

@bfoley12
bfoley12 merged commit abc3596 into materialsproject:master Nov 6, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants