first draft of slot-based array :)

This commit is contained in:
sneakers-the-rat 2024-02-05 23:02:20 -08:00
parent 9906e6c507
commit 4d27d0f636
Signed by untrusted user who does not match committer: jonny
GPG key ID: 6DCB96EF1E4D232D
13 changed files with 1276 additions and 12 deletions

View file

@ -8,3 +8,9 @@ pydanticgen
template template
``` ```
```{toctree}
:caption: Experimental Formats
slotarray
```

View file

@ -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:
```

View file

@ -4,24 +4,35 @@ Isolated generator for array classes
import warnings import warnings
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Literal
from linkml_runtime.linkml_model import ClassDefinition, SlotDefinition from linkml_runtime.linkml_model import ClassDefinition, SlotDefinition
from numpydantic.maps import flat_to_nptyping 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): class ArrayFormat(ABC):
""" """
Metaclass for different LinkML array source formats Metaclass for different LinkML array source formats
""" """
DEFINITION_TYPE: ArrayDefinitionType = None
@classmethod @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""" """Check whether a given class matches one of our subclasses definitions"""
return any([subcls.check(cls_) for subcls in cls.__subclasses__()]) return any([subcls.check(cls_) for subcls in cls.__subclasses__()])
@classmethod @classmethod
def get(cls, cls_: ClassDefinition) -> type["ArrayFormat"]: def get(cls, cls_: SourceType) -> type["ArrayFormat"]:
"""Get matching ArrayFormat subclass""" """Get matching ArrayFormat subclass"""
for subcls in cls.__subclasses__(): for subcls in cls.__subclasses__():
if subcls.check(cls_): if subcls.check(cls_):
@ -29,12 +40,12 @@ class ArrayFormat(ABC):
@classmethod @classmethod
@abstractmethod @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""" """Method for array format subclasses to check if they match a given source class"""
@classmethod @classmethod
@abstractmethod @abstractmethod
def make(cls, cls_: ClassDefinition) -> str: def make(cls, cls_: SourceType) -> str:
""" """
Make an annotation string from a given array format source class Make an annotation string from a given array format source class
""" """
@ -43,15 +54,40 @@ class ArrayFormat(ABC):
class LinkMLNDArray(ArrayFormat): class LinkMLNDArray(ArrayFormat):
""" """
Tentative linkml-arrays style NDArray 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 @classmethod
def check(cls, cls_: ClassDefinition) -> bool: def check(cls, cls_: SourceType) -> bool:
"""Check if linkml:NDArray in implements""" """Check if linkml:NDArray in implements"""
return "linkml:NDArray" in cls_.implements return "linkml:NDArray" in cls_.implements
@classmethod @classmethod
def make(cls, cls_: ClassDefinition) -> str: def make(cls, cls_: SourceType) -> str:
"""Make NDArray""" """Make NDArray"""
raise NotImplementedError("Havent implemented NDArrays yet!") raise NotImplementedError("Havent implemented NDArrays yet!")
@ -62,12 +98,12 @@ class LinkMLDataArray(ArrayFormat):
""" """
@classmethod @classmethod
def check(cls, cls_: ClassDefinition) -> bool: def check(cls, cls_: SourceType) -> bool:
"""Check if linkml:DataArray in implements""" """Check if linkml:DataArray in implements"""
return "linkml:DataArray" in cls_.implements return "linkml:DataArray" in cls_.implements
@classmethod @classmethod
def make(cls, cls_: ClassDefinition) -> str: def make(cls, cls_: SourceType) -> str:
"""Make DataArray""" """Make DataArray"""
raise NotImplementedError("Havent generated DataArray types yet!") raise NotImplementedError("Havent generated DataArray types yet!")

View file

@ -468,6 +468,19 @@ class PydanticGenerator(BasePydanticGenerator):
return slot_value 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: def serialize(self) -> str:
"""Generate LinkML models from schema!""" """Generate LinkML models from schema!"""
predefined_slot_values = {} predefined_slot_values = {}
@ -603,7 +616,7 @@ class PydanticGenerator(BasePydanticGenerator):
underscore=underscore, underscore=underscore,
enums=enums, enums=enums,
predefined_slot_values=predefined_slot_values, predefined_slot_values=predefined_slot_values,
allow_extra=self.allow_extra, # allow_extra=self.allow_extra,
metamodel_version=self.schema.metamodel_version, metamodel_version=self.schema.metamodel_version,
version=self.schema.version, version=self.schema.version,
class_isa_plus_mixins=self.get_class_isa_plus_mixins(sorted_classes), class_isa_plus_mixins=self.get_class_isa_plus_mixins(sorted_classes),

View file

@ -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()

View file

@ -32,7 +32,7 @@ Arrays larger than this size (in bytes) will be compressed and b64 encoded when
serializing to JSON. 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: def list_of_lists_schema(shape: Shape, array_type_handler: dict) -> ListSchema:

View file

@ -101,7 +101,7 @@ select = [
] ]
ignore = [ ignore = [
"ANN101", "ANN102" "ANN101", "ANN102", "UP007"
] ]
fixable = ["ALL"] fixable = ["ALL"]

0
tests/__init__.py Normal file
View file

206
tests/data/slotarray.yaml Normal file
View file

@ -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

View file

@ -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

View file

@ -4,6 +4,8 @@ from pathlib import Path
import pytest import pytest
from linkml_runtime.linkml_model import ClassDefinition, SlotDefinition from linkml_runtime.linkml_model import ClassDefinition, SlotDefinition
DATA_DIR = Path(__file__).parent / "data"
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def tmp_output_dir() -> Path: 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] NDArray[Shape["* x, * y, 3 z, 4 a"], Number]
]""" ]"""
return classdef, generated 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")

View file

@ -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 == ""