diff --git a/docs/api/linkml/index.md b/docs/api/linkml/index.md index 7034541..98849be 100644 --- a/docs/api/linkml/index.md +++ b/docs/api/linkml/index.md @@ -8,3 +8,9 @@ pydanticgen template ``` +```{toctree} +:caption: Experimental Formats + +slotarray +``` + diff --git a/docs/api/linkml/slotarray.md b/docs/api/linkml/slotarray.md new file mode 100644 index 0000000..2fe1b71 --- /dev/null +++ b/docs/api/linkml/slotarray.md @@ -0,0 +1,361 @@ +# Slot Arrays + +Will explain further in the morning :) + +See: +- [https://github.com/linkml/linkml-arrays/issues/7#issuecomment-1925999203](https://github.com/linkml/linkml-arrays/issues/7#issuecomment-1925999203) +- [https://github.com/linkml/linkml-arrays/issues/7#issuecomment-1926195529](https://github.com/linkml/linkml-arrays/issues/7#issuecomment-1926195529) + +## Working Examples + +`````{tab-set} +````{tab-item} YAML +```yaml +ExactDimension: + description: exact anonymous dimensions + attributes: + temp: + range: float + required: true + unit: + ucum_code: K + array: + dimensions: 3 +``` +```` + +````{tab-item} Pydantic +```python +class ExactDimension(ConfiguredBaseModel): + """ + exact anonymous dimensions + """ + linkml_meta: ClassVar[LinkML_Meta] = Field(LinkML_Meta(), frozen=True) + temp: NDArray[Shape[*, *, *], Float] = Field(...) +``` +```` +````` + +`````{tab-set} +````{tab-item} YAML +```yaml +ExactNamedDimension: + description: Exact named dimensions + attributes: + temp: + range: float + required: true + unit: + ucum_code: K + array: + axes: + x: + rank: 0 + alias: latitude + y: + rank: 1 + alias: longitude + t: + rank: 2 + alias: time +``` +```` +````{tab-item} Pydantic +```python +class ExactNamedDimension(ConfiguredBaseModel): + """ + Exact named dimensions + """ + linkml_meta: ClassVar[LinkML_Meta] = Field(LinkML_Meta(), frozen=True) + temp: NDArray[Shape[* latitude, * longitude, * time], Float] = Field(...) +``` +```` +````` + +`````{tab-set} +````{tab-item} YAML +```yaml +MinDimensions: + description: Minimum anonymous dimensions + attributes: + temp: + range: float + required: true + unit: + ucum_code: K + array: + dimensions: + min: 3 +``` +```` +````{tab-item} Pydantic +```python +class MinDimensions(ConfiguredBaseModel): + """ + Minimum anonymous dimensions + """ + linkml_meta: ClassVar[LinkML_Meta] = Field(LinkML_Meta(), frozen=True) + temp: NDArray[Shape[*, *, *, ...], Float] = Field(...) +``` +```` +````` + +`````{tab-set} +````{tab-item} YAML +```yaml +MaxDimensions: + description: Maximum anonymous dimensions + attributes: + temp: + range: float + required: true + unit: + ucum_code: K + array: + dimensions: + max: 3 +``` +```` +````{tab-item} Pydantic +```python +class MaxDimensions(ConfiguredBaseModel): + """ + Maximum anonymous dimensions + """ + linkml_meta: ClassVar[LinkML_Meta] = Field(LinkML_Meta(), frozen=True) + temp: Union[ + NDArray[Shape["*"], Float], + NDArray[Shape["*, *"], Float], + NDArray[Shape["*, *, *"], Float] + ] = Field(...) +``` +```` +````` + +`````{tab-set} +````{tab-item} YAML +```yaml +RangeDimensions: + description: Range of anonymous dimensions + attributes: + temp: + range: float + required: true + unit: + ucum_code: K + array: + dimensions: + min: 2 + max: 5 +``` +```` +````{tab-item} Pydantic +```python +class RangeDimensions(ConfiguredBaseModel): + """ + Range of anonymous dimensions + """ + linkml_meta: ClassVar[LinkML_Meta] = Field(LinkML_Meta(), frozen=True) + temp: Union[ + NDArray[Shape["*, *"], Float], + NDArray[Shape["*, *, *"], Float], + NDArray[Shape["*, *, *, *"], Float], + NDArray[Shape["*, *, *, *, *"], Float] + ] = Field(...) +``` +```` +````` + +`````{tab-set} +````{tab-item} YAML +```yaml +ExactCardinality: + description: An axis with a specified cardinality + attributes: + temp: + range: float + required: true + unit: + ucum_code: K + array: + axes: + x: + rank: 0 + cardinality: 3 +``` +```` +````{tab-item} Pydantic +```python +class ExactCardinality(ConfiguredBaseModel): + """ + An axis with a specified cardinality + """ + linkml_meta: ClassVar[LinkML_Meta] = Field(LinkML_Meta(), frozen=True) + temp: NDArray[Shape["3 x"], Float] = Field(...) +``` +```` +````` + +`````{tab-set} +````{tab-item} YAML +```yaml +MaxCardinality: + description: An axis with a maximum cardinality + attributes: + temp: + range: float + required: true + unit: + ucum_code: K + array: + axes: + x: + rank: 0 + cardinality: + max: 3 +``` +```` +````{tab-item} Pydantic +```python +class MaxCardinality(ConfiguredBaseModel): + """ + An axis with a maximum cardinality + """ + linkml_meta: ClassVar[LinkML_Meta] = Field(LinkML_Meta(), frozen=True) + temp: Union[ + NDArray[Shape["1 x"], Float], + NDArray[Shape["2 x"], Float], + NDArray[Shape["3 x"], Float] + ] = Field(...) +``` +```` +````` + +`````{tab-set} +````{tab-item} YAML +```yaml +RangeCardinality: + description: An axis with a min and maximum cardinality + attributes: + temp: + range: float + required: true + unit: + ucum_code: K + array: + axes: + x: + rank: 0 + cardinality: + min: 2 + max: 4 +``` +```` +````{tab-item} Pydantic +```python +class RangeCardinality(ConfiguredBaseModel): + """ + An axis with a min and maximum cardinality + """ + linkml_meta: ClassVar[LinkML_Meta] = Field(LinkML_Meta(), frozen=True) + temp: Union[ + NDArray[Shape["2 x"], Float], + NDArray[Shape["3 x"], Float], + NDArray[Shape["4 x"], Float] + ] = Field(...) +``` +```` +````` + +`````{tab-set} +````{tab-item} YAML +```yaml +ExclusiveAxes: + description: Two mutually exclusive definitions of an axis that define its different forms + attributes: + temp: + range: float + required: true + unit: + ucum_code: K + array: + axes: + x: + rank: 0 + y: + rank: 1 + rgb: + rank: 2 + cardinality: 3 + rgba: + rank: 2 + cardinality: 4 +``` +```` +````{tab-item} Pydantic +```python +class ExclusiveAxes(ConfiguredBaseModel): + """ + Two mutually exclusive definitions of an axis that define its different forms + """ + linkml_meta: ClassVar[LinkML_Meta] = Field(LinkML_Meta(), frozen=True) + temp: Union[ + NDArray[Shape["* x, * y, 3 rgb"], Float], + NDArray[Shape["* x, * y, 4 rgba"], Float] + ] = Field(...) +``` +```` +````` + +## TODO + +Any shape array + +```yaml +classes: + TemperatureDataset: + attributes: + temperatures_in_K: + range: float + multivalued: true + required: true + array: +``` + +One specified, named dimension, and any number of other dimensions + +```yaml +array: + dimensions: + min: 1 + # optionally, to be explicit: + max: null + axes: + x: + rank: 0 + alias: latitude_in_deg +``` + +Two required dimensions and two optional dimensions that will generate +a union of the combinatoric product of the optional dimensions. +Rank must be unspecified in optional dimensions + +```yaml +array: + axes: + x: + rank: 0 + y: + rank: 1 + z: + cardinality: 3 + required: false + theta: + cardinality: 4 + required: false +``` + +```{eval-rst} +.. automodule:: numpydantic.linkml.slotarray + :members: +``` \ No newline at end of file diff --git a/docs/api/ndarray.md b/docs/api/ndarray.md index 2cd0e2f..4fe336b 100644 --- a/docs/api/ndarray.md +++ b/docs/api/ndarray.md @@ -3,4 +3,4 @@ ```{eval-rst} .. automodule:: numpydantic.ndarray :members: -``` \ No newline at end of file +``` \ No newline at end of file diff --git a/numpydantic/linkml/ndarraygen.py b/numpydantic/linkml/ndarraygen.py index 0d7e590..07764a9 100644 --- a/numpydantic/linkml/ndarraygen.py +++ b/numpydantic/linkml/ndarraygen.py @@ -4,24 +4,35 @@ Isolated generator for array classes import warnings from abc import ABC, abstractmethod +from typing import Literal from linkml_runtime.linkml_model import ClassDefinition, SlotDefinition from numpydantic.maps import flat_to_nptyping +SourceType = ClassDefinition | SlotDefinition + +ArrayDefinitionType = Literal[ + "SLOT", # defined by independent slots + "CLASS_SLOT", # defined by a class used as a slot's range + "CLASS", # defined by a whole class +] + class ArrayFormat(ABC): """ Metaclass for different LinkML array source formats """ + DEFINITION_TYPE: ArrayDefinitionType = None + @classmethod - def is_array(cls, cls_: ClassDefinition) -> bool: + def is_array(cls, cls_: SourceType) -> bool: """Check whether a given class matches one of our subclasses definitions""" return any([subcls.check(cls_) for subcls in cls.__subclasses__()]) @classmethod - def get(cls, cls_: ClassDefinition) -> type["ArrayFormat"]: + def get(cls, cls_: SourceType) -> type["ArrayFormat"]: """Get matching ArrayFormat subclass""" for subcls in cls.__subclasses__(): if subcls.check(cls_): @@ -29,12 +40,12 @@ class ArrayFormat(ABC): @classmethod @abstractmethod - def check(cls, cls_: ClassDefinition) -> bool: + def check(cls, cls_: SourceType) -> bool: """Method for array format subclasses to check if they match a given source class""" @classmethod @abstractmethod - def make(cls, cls_: ClassDefinition) -> str: + def make(cls, cls_: SourceType) -> str: """ Make an annotation string from a given array format source class """ @@ -43,15 +54,40 @@ class ArrayFormat(ABC): class LinkMLNDArray(ArrayFormat): """ Tentative linkml-arrays style NDArray + + Examples: + + .. code-block:: yaml + + TemperatureMatrix: + description: A 3D array of temperatures + implements: + - linkml:NDArray + - linkml:RowOrderedArray + attributes: + values: + range: float + multivalued: true + implements: + - linkml:elements + required: true + unit: + ucum_code: K + annotations: + dimensions: 3 + + + References: + - https://github.com/linkml/linkml-model/blob/main/tests/input/examples/schema_definition-array-2.yaml """ @classmethod - def check(cls, cls_: ClassDefinition) -> bool: + def check(cls, cls_: SourceType) -> bool: """Check if linkml:NDArray in implements""" return "linkml:NDArray" in cls_.implements @classmethod - def make(cls, cls_: ClassDefinition) -> str: + def make(cls, cls_: SourceType) -> str: """Make NDArray""" raise NotImplementedError("Havent implemented NDArrays yet!") @@ -62,12 +98,12 @@ class LinkMLDataArray(ArrayFormat): """ @classmethod - def check(cls, cls_: ClassDefinition) -> bool: + def check(cls, cls_: SourceType) -> bool: """Check if linkml:DataArray in implements""" return "linkml:DataArray" in cls_.implements @classmethod - def make(cls, cls_: ClassDefinition) -> str: + def make(cls, cls_: SourceType) -> str: """Make DataArray""" raise NotImplementedError("Havent generated DataArray types yet!") diff --git a/numpydantic/linkml/pydanticgen.py b/numpydantic/linkml/pydanticgen.py index ec24d80..fd8578f 100644 --- a/numpydantic/linkml/pydanticgen.py +++ b/numpydantic/linkml/pydanticgen.py @@ -468,6 +468,19 @@ class PydanticGenerator(BasePydanticGenerator): return slot_value + def generate_python_range( + self, slot_range, slot_def: SlotDefinition, class_def: ClassDefinition + ) -> str: + """ + Overridden to handle generating slot-only arrays + """ + if ( + ArrayFormat.is_array(slot_def) + and ArrayFormat.get(slot_def).DEFINITION_TYPE == "SLOT" + ): + return ArrayFormat.get(slot_def).make(slot_def) + return super().generate_python_range(slot_range, slot_def, class_def) + def serialize(self) -> str: """Generate LinkML models from schema!""" predefined_slot_values = {} @@ -603,7 +616,7 @@ class PydanticGenerator(BasePydanticGenerator): underscore=underscore, enums=enums, predefined_slot_values=predefined_slot_values, - allow_extra=self.allow_extra, + # allow_extra=self.allow_extra, metamodel_version=self.schema.metamodel_version, version=self.schema.version, class_isa_plus_mixins=self.get_class_isa_plus_mixins(sorted_classes), diff --git a/numpydantic/linkml/slotarray.py b/numpydantic/linkml/slotarray.py new file mode 100644 index 0000000..b1f0e91 --- /dev/null +++ b/numpydantic/linkml/slotarray.py @@ -0,0 +1,357 @@ +""" +Experimental Slot-only NDArray specification + +References: + - https://github.com/linkml/linkml-arrays/issues/7#issuecomment-1925999203 + - https://github.com/linkml/linkml-arrays/issues/7#issuecomment-1926195529 + +""" +import itertools +from dataclasses import dataclass, field, make_dataclass +from typing import Optional + +import jsonasobj2 +from linkml_runtime.linkml_model import SlotDefinition as SlotDefinition_Base + +from numpydantic.linkml import ArrayFormat +from numpydantic.maps import flat_to_nptyping + + +@dataclass +class RangeAttr: + min: Optional[int] = None + max: Optional[int] = None + + +@dataclass +class DimensionsAttr(RangeAttr): + """Specification for number of axes in array""" + + pass + + +@dataclass +class CardinalityAttr(RangeAttr): + """Specification for size of a given axis""" + + pass + + +@dataclass +class Axis: + """Specification of individual axis within an NDArray""" + + rank: Optional[int] = None + alias: Optional[str] = None + cardinality: Optional[int | CardinalityAttr] = None + required: bool = True + + +@dataclass +class ArraySlot: + """Specification of an NDArray as a slot""" + + axes: Optional[dict[str, Axis]] = None + dimensions: Optional[int | DimensionsAttr] = None + + +def patch_linkml() -> type["SlotDefinition_Base"]: + """ + Monkeypatch the LinkML runtime models to add the new properties to define a slotarray :) + """ + import linkml_runtime + from linkml_runtime.linkml_model.meta import SlotDefinition + from linkml_runtime.utils import schemaview + + # make the new SlotDefinition... + newslot = make_dataclass( + "SlotDefinition", + [("array", Optional[ArraySlot], field(default=None))], + bases=(SlotDefinition,), + ) + linkml_runtime.linkml_model.meta.SlotDefinition = newslot + linkml_runtime.linkml_model.SlotDefinition = newslot + schemaview.SlotDefinition = newslot + return newslot + + +# janky local patching until we get metamodel amended +SlotDefinition = patch_linkml() + + +class SlotArrayTemplate: + def __init__(self, slot: SlotDefinition): + self.slot = slot + + def make(self) -> str: + array: ArraySlot = self.slot.array + + shape = self._handle_shape(array) + dtype = self._handle_dtype(self.slot) + if isinstance(shape, list): + shape = [f'NDArray[Shape["{a_shape}"], {dtype}]' for a_shape in shape] + union = "Union[\n" + " " * 8 + union += (",\n" + " " * 8).join(shape) + union += "\n" + " " * 4 + "]" + template = union + else: + template = f'NDArray[Shape["{shape}"], {dtype}]' + return template + + def _handle_shape(self, array: ArraySlot) -> str | list[str]: + # If we have no axes or dimensions, any shape array + if ( + getattr(array, "axes", None) is None + and getattr(array, "dimensions", None) is None + ): + return "*, ..." + elif getattr(array, "axes", None) is None: + return self._shape_from_dimension(array) + elif getattr(array, "dimensions", None) is None: + return self._shape_from_axes(array) + else: + raise NotImplementedError("Joint axis and dimension arrays") + + def _shape_from_dimension(self, array: ArraySlot): + # just dimensions + if isinstance(array.dimensions, int): + # exact number of unnamed dimensions + return ", ".join(["*"] * array.dimensions) + elif ( + getattr(array.dimensions, "min", None) is None + and getattr(array.dimensions, "max", None) is None + ): + return "*, ..." + elif ( + getattr(array.dimensions, "min", None) is not None + and getattr(array.dimensions, "max", None) is None + ): + return ", ".join(["*"] * array.dimensions.min) + ", ..." + else: + min = ( + 1 + if getattr(array.dimensions, "min", None) is None + else array.dimensions.min + ) + return [", ".join(["*"] * i) for i in range(min, array.dimensions.max + 1)] + + def _shape_from_axes(self, array: ArraySlot): + # just axes! + # FIXME: what in the hell is the matter with jsonasobj2 lmao + axes = { + name: Axis(**axis) for name, axis in jsonasobj2.as_dict(array.axes).items() + } + indices = [ + ax.rank for ax in axes.values() if getattr(ax, "rank", None) is not None + ] + # TODO: Sort by rank first + + if len(indices) == len(axes) and len(set(indices)) < len(axes): + # mutually exclusive axes + # FIXME: it's getting to be the braindead hours, this is a total nightmare of a way to check dupe idx + # for the love of god why. do it better lol. get some food and go to bed. very last thing + ax_defs = {} + for name, ax in axes.items(): + if ax.rank not in ax_defs: + ax_defs[ax.rank] = [] + ax_defs[ax.rank].append(self._make_axis(name, ax)) + ax_defs = list(ax_defs.values()) + elif len(indices) < len(axes): + raise NotImplementedError("Optional Axes") + else: + # independently defined axes, these are our friends :) + ax_defs = [self._make_axis(name, ax) for name, ax in axes.items()] + + if all([isinstance(ax_def, str) for ax_def in ax_defs]): + # no expansion needed + return ", ".join(ax_defs) + # expand such that for each axis, we get all possible combinations + ax_defs = [ + list(ax_def) if not isinstance(ax_def, list) else ax_def + for ax_def in ax_defs + ] + return [", ".join(inner_def) for inner_def in itertools.product(*ax_defs)] + + def _make_axis(self, name: str, ax: Axis) -> str | list[str]: + if getattr(ax, "alias", None): + name = ax.alias + name = name.lower() + + if getattr(ax, "rank", None) is not None and not ax.required: + # FIXME: Do this in class validation + raise ValueError("Optional axes cannot be given explicit ranks") + + if getattr(ax, "cardinality", None) is None: + return f"* {name}" + elif isinstance(ax.cardinality, int): + return f"{ax.cardinality} {name}" + else: + # FIXME: i remember why i hate dataclasses and love pydantic now lol - dataclass creation is not recursive + min = ax.cardinality.get("min", 1) + max = ax.cardinality.get("max", None) + if max is None: + raise ValueError("Cannot set minimum cardinality without maximum") + return [f"{i} {name}" for i in range(min, max + 1)] + + def _handle_dtype(self, slot: SlotDefinition) -> str: + # TODO: handle any_of, multiple typed arrays + return flat_to_nptyping[slot.range] + + +class SlotNDArray(ArrayFormat): + """ + Prospective array format specified as a slot/attribute alone + + Examples: + Any shape array + + .. code-block:: yaml + + classes: + TemperatureDataset: + attributes: + temperatures_in_K: + range: float + multivalued: true + required: true + array: + + + Exactly 3 unspecified axes/dimensions + + .. code-block:: yaml + + classes: + TemperatureDataset: + attributes: + temperatures_in_K: + range: float + multivalued: true + required: true + unit: + ucum_code: K + array: + dimensions: 3 + + Exactly 3 named dimensions + + .. code-block:: yaml + + array: + axes: + x: + rank: 0 + alias: latitude_in_deg + y: + rank: 1 + alias: longitude_in_deg + t: + rank: 2 + alias: time_in_d + + At least three, at most 5, and between 3 and 5 anonymous dimensions + + .. code-block:: yaml + + array: + dimensions: + min: 3 + + # or + dimensions: + max: 5 + + # or + dimensions: + min: 3 + max: 5 + + One specified, named dimension, and any number of other dimensions + + .. code-block:: yaml + + array: + dimensions: + min: 1 + # optionally, to be explicit: + max: null + axes: + x: + rank: 0 + alias: latitude_in_deg + + Axes with specified cardinality + + .. code-block:: yaml + + array: + axes: + x: + rank: 0 + cardinality: 3 + y: + rank: 1 + cardinality: + min: 2 + z: + rank: 2 + cardinality: + min: 3 + max: 5 + + Two required dimensions and two optional dimensions that will generate + a union of the combinatoric product of the optional dimensions. + Rank must be unspecified in optional dimensions + + .. code-block:: yaml + + array: + axes: + x: + rank: 0 + y: + rank: 1 + z: + cardinality: 3 + required: false + theta: + cardinality: 4 + required: false + + + Two required dimensions with a third dimension with two mutually exclusive forms + as indicated by their equal rank + + .. code-block:: yaml + + array: + axes: + x: + rank: 0 + y: + rank: 1 + rgb: + rank: 2 + cardinality: 3 + rgba: + rank: 2 + cardinality: 4 + + + + References: + - https://github.com/linkml/linkml-arrays/issues/7#issuecomment-1925999203 + - https://github.com/linkml/linkml-arrays/issues/7#issuecomment-1926195529 + """ + + DEFINITION_TYPE = "SLOT" + + @classmethod + def check(cls, SlotDefinition) -> bool: + if getattr(SlotDefinition, "array", None) is not None: + return True + return False + + @classmethod + def make(cls, cls_: SlotDefinition) -> str: + return SlotArrayTemplate(cls_).make() diff --git a/numpydantic/ndarray.py b/numpydantic/ndarray.py index ffd3733..3c41e73 100644 --- a/numpydantic/ndarray.py +++ b/numpydantic/ndarray.py @@ -32,7 +32,7 @@ Arrays larger than this size (in bytes) will be compressed and b64 encoded when serializing to JSON. """ -ARRAY_TYPES = np.ndarray | DaskArray | NDArrayProxy +ARRAY_TYPES = Union[np.ndarray, DaskArray, NDArrayProxy] def list_of_lists_schema(shape: Shape, array_type_handler: dict) -> ListSchema: diff --git a/pyproject.toml b/pyproject.toml index 04ff4cb..0e6854a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,7 +101,7 @@ select = [ ] ignore = [ - "ANN101", "ANN102" + "ANN101", "ANN102", "UP007" ] fixable = ["ALL"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/slotarray.yaml b/tests/data/slotarray.yaml new file mode 100644 index 0000000..5ed9719 --- /dev/null +++ b/tests/data/slotarray.yaml @@ -0,0 +1,206 @@ +id: https://example.org/arrays +name: arrays-temperature-example +title: Array Temperature Example +description: |- + Example implementation of a slot-only NDArray +license: MIT + +prefixes: + linkml: https://w3id.org/linkml/ + wgs84: http://www.w3.org/2003/01/geo/wgs84_pos# + example: https://example.org/ + +default_prefix: example + +imports: + - linkml:types + +classes: + ExactDimension: + description: exact anonymous dimensions + attributes: + temp: + range: float + required: true + unit: + ucum_code: K + array: + dimensions: 3 + + ExactNamedDimension: + description: Exact named dimensions + attributes: + temp: + range: float + required: true + unit: + ucum_code: K + array: + axes: + x: + rank: 0 + alias: latitude + y: + rank: 1 + alias: longitude + t: + rank: 2 + alias: time + + MinDimensions: + description: Minimum anonymous dimensions + attributes: + temp: + range: float + required: true + unit: + ucum_code: K + array: + dimensions: + min: 3 + + MaxDimensions: + description: Maximum anonymous dimensions + attributes: + temp: + range: float + required: true + unit: + ucum_code: K + array: + dimensions: + max: 3 + + RangeDimensions: + description: Range of anonymous dimensions + attributes: + temp: + range: float + required: true + unit: + ucum_code: K + array: + dimensions: + min: 2 + max: 5 + + MixedNamedUnnamed: + description: One specified, named dimension, and any number of other dimensions + attributes: + temp: + range: float + required: true + unit: + ucum_code: K + array: + dimensions: + min: 1 + # optionally, to be explicit: + max: null + axes: + x: + rank: 0 + alias: latitude_in_deg + + ExactCardinality: + description: An axis with a specified cardinality + attributes: + temp: + range: float + required: true + unit: + ucum_code: K + array: + axes: + x: + rank: 0 + cardinality: 3 + + MinCardinality: + description: An axis with a minimum cardinality + attributes: + temp: + range: float + required: true + unit: + ucum_code: K + array: + axes: + x: + rank: 0 + cardinality: + min: 3 + + MaxCardinality: + description: An axis with a maximum cardinality + attributes: + temp: + range: float + required: true + unit: + ucum_code: K + array: + axes: + x: + rank: 0 + cardinality: + max: 3 + + RangeCardinality: + description: An axis with a min and maximum cardinality + attributes: + temp: + range: float + required: true + unit: + ucum_code: K + array: + axes: + x: + rank: 0 + cardinality: + min: 2 + max: 4 + + OptionalAxes: + description: Two optional axes that can be present in any combination + attributes: + temp: + range: float + required: true + unit: + ucum_code: K + array: + axes: + x: + rank: 0 + y: + rank: 1 + z: + cardinality: 3 + required: false + theta: + cardinality: 4 + required: false + + ExclusiveAxes: + description: Two mutually exclusive definitions of an axis that define its different forms + attributes: + temp: + range: float + required: true + unit: + ucum_code: K + array: + axes: + x: + rank: 0 + y: + rank: 1 + rgb: + rank: 2 + cardinality: 3 + rgba: + rank: 2 + cardinality: 4 + diff --git a/tests/data/temperature_model.yaml b/tests/data/temperature_model.yaml new file mode 100644 index 0000000..796ced4 --- /dev/null +++ b/tests/data/temperature_model.yaml @@ -0,0 +1,118 @@ +id: https://example.org/arrays +name: arrays-temperature-example +title: Array Temperature Example +description: |- + from https://github.com/linkml/linkml-arrays/blob/ee2a063dbc6302c67506a2227ea9a5cc048f7584/tests/input/temperature_dataset.yaml +license: MIT + +prefixes: + linkml: https://w3id.org/linkml/ + wgs84: http://www.w3.org/2003/01/geo/wgs84_pos# + example: https://example.org/ + +default_prefix: example + +imports: + - linkml:types + +classes: + + TemperatureDataset: + tree_root: true + implements: + - linkml:DataArray + attributes: + name: + identifier: true + range: string + latitude_in_deg: + implements: + - linkml:axis + range: LatitudeSeries + required: true + annotations: + axis_index: 0 + longitude_in_deg: + implements: + - linkml:axis + range: LongitudeSeries + required: true + annotations: + axis_index: 1 + time_in_d: + implements: + - linkml:axis + range: DaySeries + required: true + annotations: + axis_index: 2 + temperatures_in_K: + implements: + - linkml:array + range: TemperatureMatrix + required: true + + TemperatureMatrix: + description: A 3D array of temperatures + implements: + - linkml:NDArray + - linkml:RowOrderedArray + attributes: + values: + range: float + multivalued: true + implements: + - linkml:elements + required: true + unit: + ucum_code: K + annotations: + dimensions: 3 + + LatitudeSeries: + description: A series whose values represent latitude + implements: + - linkml:NDArray + attributes: + values: + range: float + multivalued: true + implements: + - linkml:elements + required: true + unit: + ucum_code: deg + annotations: + dimensions: 1 + + LongitudeSeries: + description: A series whose values represent longitude + implements: + - linkml:NDArray + attributes: + values: + range: float + multivalued: true + implements: + - linkml:elements + required: true + unit: + ucum_code: deg + annotations: + dimensions: 1 + + DaySeries: + description: A series whose values represent the days since the start of the measurement period + implements: + - linkml:NDArray + attributes: + values: + range: float + multivalued: true + implements: + - linkml:elements + required: true + unit: + ucum_code: d + annotations: + dimensions: 1 diff --git a/tests/fixtures.py b/tests/fixtures.py index fc57120..c7d6e42 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -4,6 +4,8 @@ from pathlib import Path import pytest from linkml_runtime.linkml_model import ClassDefinition, SlotDefinition +DATA_DIR = Path(__file__).parent / "data" + @pytest.fixture(scope="session") def tmp_output_dir() -> Path: @@ -72,3 +74,29 @@ def nwb_linkml_array() -> tuple[ClassDefinition, str]: NDArray[Shape["* x, * y, 3 z, 4 a"], Number] ]""" return classdef, generated + + +@pytest.fixture(scope="module") +def patch_slotarray(): + import sys + + # modname = "linkml_runtime.linkml_model.meta" + # if modname in sys.modules: + # del sys.modules[modname] + + from numpydantic.linkml.slotarray import patch_linkml + import linkml_runtime + from linkml_runtime.linkml_model.meta import SlotDefinition + + original_slot = SlotDefinition + + yield patch_linkml() + + linkml_runtime.linkml_model.SlotDefinition = original_slot + + +@pytest.fixture() +def slotarray_schemaview(patch_slotarray): + from linkml_runtime.utils.schemaview import SchemaView + + return SchemaView(DATA_DIR / "slotarray.yaml") diff --git a/tests/test_linkml/test_slotarray.py b/tests/test_linkml/test_slotarray.py new file mode 100644 index 0000000..f218450 --- /dev/null +++ b/tests/test_linkml/test_slotarray.py @@ -0,0 +1,139 @@ +""" +Lol i know all these shoudl be made into parameterized test cases and i will do that after +i get them to work. don't @ me bro +""" + +import pdb + +import pytest + +from ..fixtures import patch_slotarray, slotarray_schemaview, DATA_DIR + +from numpydantic.linkml.slotarray import SlotNDArray +from numpydantic.linkml.pydanticgen import PydanticGenerator + +# -------------------------------------------------- +# Only dimensions +# -------------------------------------------------- + + +def test_exact_dimensions(slotarray_schemaview): + cls = slotarray_schemaview.get_class("ExactDimension") + array = cls.attributes["temp"] + annotation = SlotNDArray.make(array) + # hardcoding for now... + assert annotation == 'NDArray[Shape["*, *, *"], Float]' + + +def test_min_dimensions(slotarray_schemaview): + cls = slotarray_schemaview.get_class("MinDimensions") + array = cls.attributes["temp"] + annotation = SlotNDArray.make(array) + assert annotation == 'NDArray[Shape["*, *, *, ..."], Float]' + + +def test_max_dimensions(slotarray_schemaview): + cls = slotarray_schemaview.get_class("MaxDimensions") + array = cls.attributes["temp"] + annotation = SlotNDArray.make(array) + assert ( + annotation + == """Union[ + NDArray[Shape["*"], Float], + NDArray[Shape["*, *"], Float], + NDArray[Shape["*, *, *"], Float] + ]""" + ) + + +def test_range_dimensions(slotarray_schemaview): + cls = slotarray_schemaview.get_class("RangeDimensions") + array = cls.attributes["temp"] + annotation = SlotNDArray.make(array) + assert ( + annotation + == """Union[ + NDArray[Shape["*, *"], Float], + NDArray[Shape["*, *, *"], Float], + NDArray[Shape["*, *, *, *"], Float], + NDArray[Shape["*, *, *, *, *"], Float] + ]""" + ) + + +def test_exact_cardinality(slotarray_schemaview): + cls = slotarray_schemaview.get_class("ExactCardinality") + array = cls.attributes["temp"] + annotation = SlotNDArray.make(array) + assert annotation == """NDArray[Shape["3 x"], Float]""" + + +def test_min_cardinality(slotarray_schemaview): + cls = slotarray_schemaview.get_class("MinCardinality") + array = cls.attributes["temp"] + with pytest.raises(ValueError): + # no way to specify ranges in nptyping ranges yet, this would go to infinity + SlotNDArray.make(array) + + +def test_max_cardinality(slotarray_schemaview): + cls = slotarray_schemaview.get_class("MaxCardinality") + array = cls.attributes["temp"] + annotation = SlotNDArray.make(array) + assert ( + annotation + == """Union[ + NDArray[Shape["1 x"], Float], + NDArray[Shape["2 x"], Float], + NDArray[Shape["3 x"], Float] + ]""" + ) + + +def test_range_cardinality(slotarray_schemaview): + cls = slotarray_schemaview.get_class("RangeCardinality") + array = cls.attributes["temp"] + annotation = SlotNDArray.make(array) + + assert ( + annotation + == """Union[ + NDArray[Shape["2 x"], Float], + NDArray[Shape["3 x"], Float], + NDArray[Shape["4 x"], Float] + ]""" + ) + + +def test_exclusive_axes(slotarray_schemaview): + cls = slotarray_schemaview.get_class("ExclusiveAxes") + array = cls.attributes["temp"] + annotation = SlotNDArray.make(array) + assert ( + annotation + == """Union[ + NDArray[Shape["* x, * y, 3 rgb"], Float], + NDArray[Shape["* x, * y, 4 rgba"], Float] + ]""" + ) + + +# def test_generate_schema(patch_slotarray): +# schema = DATA_DIR / "slotarray.yaml" +# generator = PydanticGenerator(schema) +# serialized = generator.serialize() +# pdb.set_trace() + + +# def test_optional_axes(slotarray_schemaview): +# cls = slotarray_schemaview.get_class("OptionalAxes") +# array = cls.attributes["temp"] +# annotation = SlotNDArray.make(array) +# # assert annotation == "" +# +# +# def test_mixed_named_unnamed_dimensions(slotarray_schemaview): +# cls = slotarray_schemaview.get_class("MixedNamedUnnamed") +# array = cls.attributes["temp"] +# annotation = SlotNDArray.make(array) +# # assert annotation == ""