"""The metadata module, currently experimental.
The metadata works through the various datatypes in XTGeo. For example::
>>> import xtgeo
>>> surf = xtgeo.surface_from_file(surface_dir + "/topreek_rota.gri")
>>> surf.metadata.required
dict([('ncol', 554),...
>>> surf.metadata.optional.mean = surf.values.mean()
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import xtgeo
from xtgeo.common.constants import UNDEF
from xtgeo.common.log import null_logger
if TYPE_CHECKING:
from datetime import datetime
from xtgeo.cube.cube1 import Cube
from xtgeo.grid3d.grid import Grid, GridProperty
from xtgeo.surface.regular_surface import RegularSurface
from xtgeo.well.well1 import Well
logger = null_logger(__name__)
class _OptionalMetaData:
"""Optional metadata are not required, but keys are limited.
A limited sets of possible keys are available, and they can modified. This
class can also have validation methods.
"""
__slots__ = (
"_name",
"_shortname",
"_datatype",
"_md5sum",
"_description",
"_crs",
"_datetime",
"_deltadatetime",
"_visuals",
"_domain",
"_user",
"_field",
"_source",
"_modelid",
"_ensembleid",
"_units",
"_mean",
"_stddev",
"_percentiles",
)
def __init__(self) -> None:
self._name = "A Longer Descriptive Name e.g. from SMDA"
self._shortname = "TheShortName"
self._datatype: str | None = None
self._md5sum: str | None = None
self._description = "Some description"
self._crs = None
self._datetime: datetime | str | None = None
self._deltadatetime = None
self._visuals = {"colortable": "rainbow", "lower": None, "upper": None}
self._domain = "depth"
self._units: str = "metric" # TODO: Could be an enum to pick from
self._mean = None
self._stddev = None
self._percentiles = None
self._user = "anonymous"
self._field = "nofield"
self._ensembleid = None
self._modelid = None
self._source = "unknown"
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, newname: str) -> None:
# TODO: validation
self._name = newname
@property
def datetime(self) -> datetime | str | None:
return self._datetime
@datetime.setter
def datetime(self, newdate: datetime | str) -> None:
# TODO: validation
self._datetime = newdate
@property
def shortname(self) -> str:
return self._shortname
@shortname.setter
def shortname(self, newname: str) -> None:
if not isinstance(newname, str):
raise ValueError("The shortname must be a string.")
if len(newname) >= 32:
raise ValueError("The shortname length must less or equal 32 letters.")
self._shortname = newname
@property
def description(self) -> str:
return self._description
@description.setter
def description(self, newstr: str) -> None:
if not isinstance(newstr, str):
raise ValueError("The description must be a string.")
if len(newstr) >= 64:
raise ValueError("The description length must less or equal 64 letters.")
invalids = r"/$<>[]:\&%"
if set(invalids).intersection(newstr):
raise ValueError("The description contains invalid characters such as /.")
self._description = newstr
@property
def md5sum(self) -> str | None:
"""Set or get the md5 checksum of file content.
See generate_hash() method in e.g. RegularSurface.
"""
return self._md5sum
@md5sum.setter
def md5sum(self, newhash: str) -> None:
# TODO: validation
self._md5sum = newhash
@property
def units(self) -> str:
return self._units
@units.setter
def units(self, newunits: str) -> None:
if not isinstance(newunits, str):
raise ValueError("Units must be a string.")
# TODO: could be an enum to pick from
self._units = newunits
def get_meta(self) -> dict[str, Any]:
"""Return metadata as a dict."""
meta = {}
for key in self.__slots__:
newkey = key[1:]
meta[newkey] = getattr(self, key)
return meta
class MetaData:
"""Generic metadata class, not intended to be used directly."""
def __init__(self) -> None:
"""Generic metadata class __init__, not to be used directly."""
self._required: dict[str, Any] = {}
self._optional = _OptionalMetaData()
self._freeform = {}
self._freeform = {"smda": "whatever"}
def __eq__(self, other: object) -> bool:
"""Check if two MetaData instances are equal."""
if not isinstance(other, MetaData):
return NotImplemented
if self is other:
return True
return self.get_metadata() == other.get_metadata()
def get_metadata(self) -> dict[str, Any]:
"""Get all metadata that are present."""
allmeta = {}
allmeta["_required_"] = self._required
allmeta["_optional_"] = self._optional.get_meta()
allmeta["_freeform_"] = self._freeform
return allmeta
@property
def optional(self) -> dict[str, Any]:
"""Return or set optional metadata.
When setting optional names, it can be done in several ways...
surf.metadata.optional.name = "New name"
"""
# return a copy of the instance; the reason for this is to avoid manipulation
# without validation
return self._optional.get_meta()
@optional.setter
def optional(self, indict: dict[str, Any]) -> None:
# setting the optional key, including validation
if not isinstance(indict, dict):
raise ValueError(f"Input must be a dictionary, not a {type(indict)}")
for key, value in indict.items():
setattr(self._optional, "_" + key, value)
@property
def opt(self) -> _OptionalMetaData:
"""Return the metadata optional instance.
This makes access to the _OptionalMetaData instance.
Example::
>>> import xtgeo
>>> surf = xtgeo.surface_from_file(surface_dir + "/topreek_rota.gri")
>>> surf.metadata.opt.shortname = "TopValysar"
"""
return self._optional
@property
def freeform(self) -> dict[str, Any]:
"""Get or set the current freeform metadata dictionary."""
return self._freeform
@freeform.setter
def freeform(self, adict: dict[str, Any]) -> None:
"""Freeform is a whatever you want set, without any validation."""
self._freeform = adict.copy()
def generate_fmu_name(self) -> str:
"""Generate FMU name on form xxxx--yyyy--date but no suffix."""
fname = ""
first = "prefix"
fname += first
fname += "--"
fname += self._optional._shortname.lower()
if self._optional._datetime:
fname += "--"
fname += str(self._optional._datetime)
return fname
class MetaDataTriangulatedSurface(MetaData):
"""
Metadata for TriangulatedSurface() objects.
"""
# Required to contain exactly this set of keys
REQUIRED: dict[str, int] = {
"num_vertices": -1,
"num_triangles": -1,
}
def __init__(self) -> None:
"""Docstring."""
super().__init__()
self._required = self.REQUIRED.copy()
self._optional._datatype = "Triangulated Surface"
def __eq__(self, other: object) -> bool:
"""Check if two MetaDataTriangulatedSurface instances are equal."""
if not isinstance(other, MetaDataTriangulatedSurface):
return NotImplemented
return super().__eq__(other)
@property
def required(self) -> dict[str, int]:
"""Get set of required metadata."""
return self._required
@required.setter
def required(self, value: dict[str, int]) -> None:
"""
Update required metadata from a dictionary.
The rest of the metadata (optional and freeform) remains unchanged.
Args:
value: Dict with keys matching MetaDataTriangulatedSurface.REQUIRED.
Raises:
ValueError: If required keys are missing.
"""
MetaDataTriangulatedSurface.validate_required(
required_metadata=value, metadata=self, equal_values=False
)
self._required = value.copy()
@staticmethod
def validate_required(
required_metadata: dict[str, int],
metadata: MetaDataTriangulatedSurface,
equal_values: bool = False,
) -> None:
"""
Verify the REQUIRED metadata keys and values for a TriangulatedSurface.
Keys must match exactly.
Optional and freeform metadata are not checked.
Args:
required_metadata: Dict with keys matching
MetaDataTriangulatedSurface.REQUIRED, and their real values set from
the TriangulatedSurface instance.
metadata: The metadata instance to validate.
equal_values:
If True, check that the values in required_metadata equal
the values in metadata._required.
If False, only basic tests are performed
Returns:
True if all required fields match.
"""
if not isinstance(required_metadata, dict):
raise TypeError("required metadata must be a dict")
if not isinstance(metadata, MetaDataTriangulatedSurface):
raise TypeError("metadata is not a MetaDataTriangulatedSurface()")
# Verify that the set of keys in required_metadata is exactly the same as
# the set of keys in metadata.REQUIRED
if set(required_metadata.keys()) != set(metadata.REQUIRED.keys()):
raise ValueError(
f"'required_metadata' keys do not match expected keys. "
f"Expected keys {set(metadata.REQUIRED.keys())}, "
f"received {set(required_metadata.keys())}"
)
# Verify that the set of keys in current metadata is exactly the same as
# the set of keys in metadata.REQUIRED
if set(metadata._required.keys()) != set(metadata.REQUIRED.keys()):
raise ValueError(
f"'metadata' keys (current metadata) do not match expected keys. "
f"Expected keys {set(metadata.REQUIRED.keys())}, "
f"received {set(metadata._required.keys())}"
)
if not all(
isinstance(required_metadata[key], int) for key in required_metadata
):
raise ValueError("All required metadata values must be integers.")
if equal_values:
if not all(
required_metadata[key] == metadata._required[key]
for key in required_metadata
):
raise ValueError(
"Required metadata values must be equal to "
"the values in the metadata."
)
else:
# Basic tests
if not all(required_metadata[key] > 0 for key in required_metadata):
raise ValueError(
"All required metadata values must be positive integers."
)