From 6f7b703b7c89bcf6702691533bc0d01cc2455a9a Mon Sep 17 00:00:00 2001 From: sanbrock <45483558+sanbrock@users.noreply.github.com> Date: Fri, 25 Oct 2024 18:17:57 +0200 Subject: [PATCH] replace BasicEln (#451) * code for being added when NOMAD GUI supports mixed use of use_full_storage in quantities * removing NXsuffices; removing nexus section and using it inside data * fixing tests * fix for handling problem where NO NXentry found * rename group names if they are used in the BaseSection class * restructure nomad tests * temporarily install nomad feature branch in tests * add test for schema * ignore myp error on nexus_schema.NeXus * use renaming function in tests * bring XML_NAMESPACES back to schema * rename to __XML_NAMESPACES * include field and attribute renaming, capitalization * small docs change * remove capitalization * temporarily install nomad feature branch in tests * capitalise NX class names in NOMAD * instead of using the buggy m_set_quantity_attribute of a section, use m_set_attribute directly on the quantity * update nomad branch to check against * code simplification according to review suggestion --------- Co-authored-by: Lukas Pielsticker <50139597+lukaspie@users.noreply.github.com> --- .github/workflows/pytest.yml | 2 +- src/pynxtools/nexus/nexus.py | 7 +- src/pynxtools/nomad/parser.py | 72 +++++------------ src/pynxtools/nomad/schema.py | 99 +++++++++++++---------- src/pynxtools/nomad/utils.py | 65 ++++++++++++++-- tests/data/nomad/NXlauetof.hdf5 | Bin 0 -> 21472 bytes tests/nexus/test_nexus.py | 8 +- tests/nomad/test_metainfo_schema.py | 117 ++++++++++++++++++++++++++++ tests/nomad/test_parsing.py | 109 +++++--------------------- 9 files changed, 282 insertions(+), 197 deletions(-) create mode 100644 tests/data/nomad/NXlauetof.hdf5 create mode 100644 tests/nomad/test_metainfo_schema.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 034091f82..1a14e2804 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -36,7 +36,7 @@ jobs: - name: Install nomad if: "${{ matrix.python_version != '3.8' && matrix.python_version != '3.12'}}" run: | - uv pip install nomad-lab@git+https://gitlab.mpcdf.mpg.de/nomad-lab/nomad-FAIR.git + uv pip install nomad-lab@git+https://gitlab.mpcdf.mpg.de/nomad-lab/nomad-FAIR.git@Sprint_Nomad_BaseSection - name: Install pynx run: | uv pip install ".[dev]" diff --git a/src/pynxtools/nexus/nexus.py b/src/pynxtools/nexus/nexus.py index c4f06d4fe..679d4f94d 100644 --- a/src/pynxtools/nexus/nexus.py +++ b/src/pynxtools/nexus/nexus.py @@ -5,8 +5,7 @@ import os import sys from functools import lru_cache - -from typing import Optional, Union, List, Any +from typing import Any, List, Optional, Union import click import h5py @@ -16,6 +15,7 @@ from pynxtools.definitions.dev_tools.utils.nxdl_utils import ( add_base_classes, check_attr_name_nxdl, + decode_or_not, get_best_child, get_hdf_info_parent, get_local_name_from_xml, @@ -29,7 +29,6 @@ try_find_units, walk_elist, write_doc_string, - decode_or_not, ) @@ -378,6 +377,8 @@ def get_inherited_hdf_nodes( # let us start with the given definition file if hdf_node is None: raise ValueError("hdf_node must not be None") + if nx_name == "NO NXentry found": + return (None, [], []) elist = [] # type: ignore[var-annotated] add_base_classes(elist, nx_name, elem) nxdl_elem_path = [elist[0]] diff --git a/src/pynxtools/nomad/parser.py b/src/pynxtools/nomad/parser.py index 2e65c6686..ea3dd3c3e 100644 --- a/src/pynxtools/nomad/parser.py +++ b/src/pynxtools/nomad/parser.py @@ -24,7 +24,8 @@ try: from ase.data import chemical_symbols from nomad.atomutils import Formula - from nomad.datamodel import EntryArchive + from nomad.datamodel import EntryArchive, EntryMetadata + from nomad.datamodel.data import EntryData from nomad.datamodel.results import Material, Results from nomad.metainfo import MSection from nomad.metainfo.util import MQuantity, MSubSectionList, resolve_variadic_name @@ -39,23 +40,8 @@ import pynxtools.nomad.schema as nexus_schema from pynxtools.nexus.nexus import HandleNexus - -__REPLACEMENT_FOR_NX = "BS" -__REPLACEMENT_LEN = len(__REPLACEMENT_FOR_NX) - - -def _rename_nx_to_nomad(name: str) -> Optional[str]: - """ - Rename the NXDL name to NOMAD. - For example: NXdata -> BSdata, - except NXobject -> NXobject - """ - if name == "NXobject": - return name - if name is not None: - if name.startswith("NX"): - return name.replace("NX", __REPLACEMENT_FOR_NX) - return name +from pynxtools.nomad.utils import __REPLACEMENT_FOR_NX +from pynxtools.nomad.utils import __rename_nx_for_nomad as rename_nx_for_nomad def _to_group_name(nx_node: ET.Element): @@ -63,9 +49,7 @@ def _to_group_name(nx_node: ET.Element): Normalise the given group name """ # assuming always upper() is incorrect, e.g. NXem_msr is a specific one not EM_MSR! - grp_nm = nx_node.attrib.get( - "name", nx_node.attrib["type"][__REPLACEMENT_LEN:].upper() - ) + grp_nm = nx_node.attrib.get("name", nx_node.attrib["type"][2:].upper()) return grp_nm @@ -109,6 +93,8 @@ def _to_section( # no need to change section for quantities and attributes return current + nomad_def_name = rename_nx_for_nomad(nomad_def_name, is_group=True) + # for groups, get the definition from the package new_def = current.m_def.all_sub_sections[nomad_def_name] @@ -233,9 +219,7 @@ def _populate_data( raise Warning( "setting attribute attempt before creating quantity" ) - current.m_set_quantity_attribute( - quantity.name, attr_name, attr_value - ) + quantity.m_set_attribute(attr_name, attr_value) except Exception as e: self._logger.warning( "error while setting attribute", @@ -307,28 +291,16 @@ def _populate_data( # may need to check if the given unit is in the allowable list try: current.m_set(metainfo_def, field) - current.m_set_quantity_attribute( - data_instance_name, "m_nx_data_path", hdf_node.name - ) - current.m_set_quantity_attribute( - data_instance_name, "m_nx_data_file", self.nxs_fname - ) + field.m_set_attribute("m_nx_data_path", hdf_node.name) + field.m_set_attribute("m_nx_data_file", self.nxs_fname) if field_stats is not None: # TODO _add_additional_attributes function has created these nx_data_* # attributes speculatively already so if the field_stats is None # this will cause unpopulated attributes in the GUI - current.m_set_quantity_attribute( - data_instance_name, "nx_data_mean", field_stats[0] - ) - current.m_set_quantity_attribute( - data_instance_name, "nx_data_var", field_stats[1] - ) - current.m_set_quantity_attribute( - data_instance_name, "nx_data_min", field_stats[2] - ) - current.m_set_quantity_attribute( - data_instance_name, "nx_data_max", field_stats[3] - ) + field.m_set_attribute("nx_data_mean", field_stats[0]) + field.m_set_attribute("nx_data_var", field_stats[1]) + field.m_set_attribute("nx_data_min", field_stats[2]) + field.m_set_attribute("nx_data_max", field_stats[3]) except Exception as e: self._logger.warning( "error while setting field", @@ -349,7 +321,8 @@ def __nexus_populate(self, params: dict, attr=None): # pylint: disable=W0613 hdf_path: str = hdf_info["hdf_path"] hdf_node = hdf_info["hdf_node"] if nx_def is not None: - nx_def = _rename_nx_to_nomad(nx_def) + nx_def = rename_nx_for_nomad(nx_def) + if nx_path is None: return @@ -489,8 +462,9 @@ def parse( child_archives: Dict[str, EntryArchive] = None, ) -> None: self.archive = archive - self.archive.m_create(nexus_schema.NeXus) # type: ignore # pylint: disable=no-member - self.nx_root = self.archive.nexus + self.nx_root = nexus_schema.NeXus() # type: ignore # pylint: disable=no-member + + self.archive.data = self.nx_root self._logger = logger if logger else get_logger(__name__) self._clear_class_refs() @@ -500,14 +474,10 @@ def parse( # TODO: domain experiment could also be registered if archive.metadata is None: - return + archive.metadata = EntryMetadata() # Normalise experiment type - app_def: str = "" - for var in dir(archive.nexus): - if getattr(archive.nexus, var, None) is not None: - app_def = var - break + app_def = str(self.nx_root).split("(")[1].split(")")[0].split(",")[0] if archive.metadata.entry_type is None: archive.metadata.entry_type = app_def archive.metadata.domain = "nexus" diff --git a/src/pynxtools/nomad/schema.py b/src/pynxtools/nomad/schema.py index 5fbbb6ac8..98dd86f85 100644 --- a/src/pynxtools/nomad/schema.py +++ b/src/pynxtools/nomad/schema.py @@ -31,7 +31,8 @@ try: from nomad import utils - from nomad.datamodel import EntryArchive + from nomad.datamodel import EntryArchive, EntryMetadata + from nomad.datamodel.data import EntryData from nomad.datamodel.metainfo.basesections import ( BaseSection, Component, @@ -73,7 +74,7 @@ from pynxtools import get_definitions_url from pynxtools.definitions.dev_tools.utils.nxdl_utils import get_nexus_definitions_path -from pynxtools.nomad.utils import __REPLACEMENT_FOR_NX, __rename_nx_to_nomad +from pynxtools.nomad.utils import __REPLACEMENT_FOR_NX, __rename_nx_for_nomad # __URL_REGEXP from # https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url @@ -82,6 +83,7 @@ r"(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+" r'(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:\'".,<>?«»“”‘’]))' ) + # noinspection HttpUrlsUsage __XML_NAMESPACES = {"nx": "http://definition.nexusformat.org/nxdl/3.1"} @@ -93,11 +95,11 @@ __logger = get_logger(__name__) __BASESECTIONS_MAP: Dict[str, Any] = { - "BSfabrication": [Instrument], - "BSsample": [CompositeSystem], - "BSsample_component": [Component], - "BSidentifier": [EntityReference], - # "BSobject": BaseSection, + __rename_nx_for_nomad("NXfabrication"): [Instrument], + __rename_nx_for_nomad("NXsample"): [CompositeSystem], + __rename_nx_for_nomad("NXsample_component"): [Component], + __rename_nx_for_nomad("NXidentifier"): [EntityReference], + # "object": BaseSection, } @@ -293,8 +295,6 @@ def __to_section(name: str, **kwargs) -> Section: class nexus definition. """ - # name = __rename_nx_to_nomad(name) - if name in __section_definitions: section = __section_definitions[name] section.more.update(**kwargs) @@ -372,7 +372,7 @@ def __create_attributes(xml_node: ET.Element, definition: Union[Section, Quantit todo: account for more attributes of attribute, e.g., default, minOccurs """ for attribute in xml_node.findall("nx:attribute", __XML_NAMESPACES): - name = attribute.get("name") + "__attribute" + name = __rename_nx_for_nomad(attribute.get("name"), is_attribute=True) nx_enum = __get_enumeration(attribute) if nx_enum: @@ -465,7 +465,8 @@ def __create_field(xml_node: ET.Element, container: Section) -> Quantity: # name assert "name" in xml_attrs, "Expecting name to be present" - name = xml_attrs["name"] + "__field" + + name = __rename_nx_for_nomad(xml_attrs["name"], is_field=True) # type nx_type = xml_attrs.get("type", "NX_CHAR") @@ -548,10 +549,11 @@ def __create_group(xml_node: ET.Element, root_section: Section): xml_attrs = group.attrib assert "type" in xml_attrs, "Expecting type to be present" - nx_type = __rename_nx_to_nomad(xml_attrs["type"]) + nx_type = __rename_nx_for_nomad(xml_attrs["type"]) nx_name = xml_attrs.get("name", nx_type) - group_section = Section(validate=VALIDATE, nx_kind="group", name=nx_name) + section_name = __rename_nx_for_nomad(nx_name, is_group=True) + group_section = Section(validate=VALIDATE, nx_kind="group", name=section_name) __attach_base_section(group_section, root_section, __to_section(nx_type)) __add_common_properties(group, group_section) @@ -559,10 +561,11 @@ def __create_group(xml_node: ET.Element, root_section: Section): nx_name = xml_attrs.get( "name", nx_type.replace(__REPLACEMENT_FOR_NX, "").upper() ) + subsection_name = __rename_nx_for_nomad(nx_name, is_group=True) group_subsection = SubSection( section_def=group_section, nx_kind="group", - name=nx_name, + name=subsection_name, repeats=__if_repeats(nx_name, xml_attrs.get("maxOccurs", "0")), variable=__if_template(nx_name), ) @@ -604,7 +607,7 @@ def __create_class_section(xml_node: ET.Element) -> Section: nx_type = xml_attrs["type"] nx_category = xml_attrs["category"] - nx_name = __rename_nx_to_nomad(nx_name) + nx_name = __rename_nx_for_nomad(nx_name) class_section: Section = __to_section( nx_name, nx_kind=nx_type, nx_category=nx_category ) @@ -612,7 +615,7 @@ def __create_class_section(xml_node: ET.Element) -> Section: nomad_base_sec_cls = __BASESECTIONS_MAP.get(nx_name, [BaseSection]) if "extends" in xml_attrs: - nx_base_sec = __to_section(__rename_nx_to_nomad(xml_attrs["extends"])) + nx_base_sec = __to_section(__rename_nx_for_nomad(xml_attrs["extends"])) class_section.base_sections = [nx_base_sec] + [ cls.m_def for cls in nomad_base_sec_cls ] @@ -779,7 +782,9 @@ def init_nexus_metainfo(): # We take the application definitions and create a common parent section that allows # to include nexus in an EntryArchive. - nexus_section = Section(validate=VALIDATE, name=__GROUPING_NAME) + nexus_section = Section( + validate=VALIDATE, name=__GROUPING_NAME, label=__GROUPING_NAME + ) # try: # load_nexus_schema('') @@ -791,10 +796,6 @@ def init_nexus_metainfo(): # pass nexus_metainfo_package = __create_package_from_nxdl_directories(nexus_section) - EntryArchive.nexus = SubSection(name="nexus", section_def=nexus_section) - EntryArchive.nexus.init_metainfo() - EntryArchive.m_def.sub_sections.append(EntryArchive.nexus) - nexus_metainfo_package.section_definitions.append(nexus_section) # We need to initialize the metainfo definitions. This is usually done automatically, @@ -813,6 +814,9 @@ def init_nexus_metainfo(): sections.append(section) for section in sections: + # TODO: add when quantities with mixed use_full_storage are supported by GUI + # if not (str(section).startswith("nexus.")): + # continue __add_additional_attributes(section) for quantity in section.quantities: __add_additional_attributes(quantity) @@ -827,16 +831,20 @@ def init_nexus_metainfo(): init_nexus_metainfo() -def normalize_BSfabrication(self, archive, logger): - """Normalizer for BSfabrication section.""" - current_cls = __section_definitions["BSfabrication"].section_cls +def normalize_fabrication(self, archive, logger): + """Normalizer for fabrication section.""" + current_cls = __section_definitions[ + __rename_nx_for_nomad("NXfabrication") + ].section_cls super(current_cls, self).normalize(archive, logger) self.lab_id = "Hello" -def normalize_BSsample_component(self, archive, logger): - """Normalizer for BSsample_component section.""" - current_cls = __section_definitions["BSsample_component"].section_cls +def normalize_sample_component(self, archive, logger): + """Normalizer for sample_component section.""" + current_cls = __section_definitions[ + __rename_nx_for_nomad("NXsample_component") + ].section_cls if self.name__field: self.name = self.name__field if self.mass__field: @@ -845,24 +853,31 @@ def normalize_BSsample_component(self, archive, logger): super(current_cls, self).normalize(archive, logger) -def normalize_BSsample(self, archive, logger): - """Normalizer for BSsample section.""" - current_cls = __section_definitions["BSsample"].section_cls +def normalize_sample(self, archive, logger): + """Normalizer for sample section.""" + current_cls = __section_definitions[__rename_nx_for_nomad("NXsample")].section_cls if self.name__field: self.name = self.name__field - # one could also copy local ids to BSidentifier for search purposes + # one could also copy local ids to identifier for search purposes super(current_cls, self).normalize(archive, logger) -def normalize_BSidentifier(self, archive, logger): - """Normalizer for BSidentifier section.""" +def normalize_identifier(self, archive, logger): + """Normalizer for identifier section.""" def create_Entity(lab_id, archive, f_name): + # TODO: use this instead of BasicEln() when use_full_storage is properly supported by the GUI + # entitySec = Entity() + # entitySec.lab_id = lab_id + # entity = EntryArchive ( + # data = entitySec, + # m_context=archive.m_context, + # metadata=EntryMetadata(entry_type = "identifier"), #upload_id=archive.m_context.upload_id, + # ) + # with archive.m_context.raw_file(f_name, 'w') as f_obj: + # json.dump(entity.m_to_dict(with_meta=True), f_obj) entity = BasicEln() entity.lab_id = lab_id - entity.entity = Entity() - entity.entity.lab_id = lab_id - with archive.m_context.raw_file(f_name, "w") as f_obj: json.dump( {"data": entity.m_to_dict(with_meta=True, include_derived=True)}, @@ -880,7 +895,9 @@ def get_entry_reference(archive, f_name): return f"/entries/{entry_id}/archive#/data" - current_cls = __section_definitions["BSidentifier"].section_cls + current_cls = __section_definitions[ + __rename_nx_for_nomad("NXidentifier") + ].section_cls # super(current_cls, self).normalize(archive, logger) if self.identifier__field: logger.info(f"{self.identifier__field} - identifier received") @@ -896,10 +913,10 @@ def get_entry_reference(archive, f_name): __NORMALIZER_MAP: Dict[str, Any] = { - "BSfabrication": normalize_BSfabrication, - "BSsample": normalize_BSsample, - "BSsample_component": normalize_BSsample_component, - "BSidentifier": normalize_BSidentifier, + __rename_nx_for_nomad("NXfabrication"): normalize_fabrication, + __rename_nx_for_nomad("NXsample"): normalize_sample, + __rename_nx_for_nomad("NXsample_component"): normalize_sample_component, + __rename_nx_for_nomad("NXidentifier"): normalize_identifier, } # Handling nomad BaseSection and other inherited Section from BaseSection diff --git a/src/pynxtools/nomad/utils.py b/src/pynxtools/nomad/utils.py index 203e52bc7..794a94e60 100644 --- a/src/pynxtools/nomad/utils.py +++ b/src/pynxtools/nomad/utils.py @@ -18,18 +18,67 @@ from typing import Optional -__REPLACEMENT_FOR_NX = "BS" +__REPLACEMENT_FOR_NX = "" +# This is a list of NeXus group names that are not allowed because they are defined as quantities in the BaseSection class. +UNALLOWED_GROUP_NAMES = {"name", "datetime", "lab_id", "description"} -def __rename_nx_to_nomad(name: str) -> Optional[str]: + +def __rename_classes_in_nomad(nx_name: str) -> Optional[str]: + """ + Modify group names that conflict with NOMAD due to being defined as quantities + in the BaseSection class by appending '__group' to those names. + + Some quantities names names are reserved in the BaseSection class (or even higher up in metainfo), + and thus require renaming to avoid collisions. + + Args: + nx_name (str): The original group name. + + Returns: + Optional[str]: The modified group name with '__group' appended if it's in + UNALLOWED_GROUP_NAMES, or the original name if no change is needed. + """ + return nx_name + "__group" if nx_name in UNALLOWED_GROUP_NAMES else nx_name + + +def __rename_nx_for_nomad( + name: str, + is_group: bool = False, + is_field: bool = False, + is_attribute: bool = False, +) -> Optional[str]: """ - Rename the NXDL name to NOMAD. - For example: NXdata -> BSdata, - except NXobject -> NXobject + Rename NXDL names for compatibility with NOMAD, applying specific rules + based on the type of the NeXus concept. (group, field, or attribute). + + - NXobject is unchanged. + - NX-prefixed names (e.g., NXdata) are renamed by replacing 'NX' with a custom string. + - Group names are passed to __rename_classes_in_nomad(), and the result is capitalized. + - Fields and attributes have '__field' or '__attribute' appended, respectively. + + Args: + name (str): The NXDL name. + is_group (bool): Whether the name represents a group. + is_field (bool): Whether the name represents a field. + is_attribute (bool): Whether the name represents an attribute. + + Returns: + Optional[str]: The renamed NXDL name, with group names capitalized, + or None if input is invalid. """ if name == "NXobject": return name - if name is not None: - if name.startswith("NX"): - return name.replace("NX", __REPLACEMENT_FOR_NX) + + if name and name.startswith("NX"): + name = __REPLACEMENT_FOR_NX + name[2:] + name = name[0].upper() + name[1:] + + if is_group: + name = __rename_classes_in_nomad(name) + elif is_field: + name += "__field" + elif is_attribute: + name += "__attribute" + return name diff --git a/tests/data/nomad/NXlauetof.hdf5 b/tests/data/nomad/NXlauetof.hdf5 new file mode 100644 index 0000000000000000000000000000000000000000..40b524d6e711b24e0cbc66df5feed7bdd2b0b44f GIT binary patch literal 21472 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 f;g1sNpV^Y+#p6<(66x~b?>Vnj{xg_7JR