Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 9 additions & 14 deletions sasdata/metadata.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""
Contains classes describing the metadata for a scattering run

The metadata is structures around the CANSas format version 1.1, found at
The metadata is structured around the CANSas format version 1.1, found at
https://www.cansas.org/formats/canSAS1d/1.1/doc/specification.html

Metadata from other file formats should be massaged to fit into the data classes presented here.
Expand Down Expand Up @@ -142,6 +142,7 @@ def as_h5(self, group: h5py.Group):
if self.slit_length:
self.slit_length.as_h5(group, "slit_length")


@dataclass(kw_only=True)
class Aperture:
distance: Quantity[float] | None
Expand All @@ -168,7 +169,6 @@ def from_json(obj):
type_=obj["type"],
)


def as_h5(self, group: h5py.Group):
"""Export data onto an HDF5 group"""
if self.distance is not None:
Expand All @@ -184,7 +184,6 @@ def as_h5(self, group: h5py.Group):
size_group.attrs["name"] = self.size_name



@dataclass(kw_only=True)
class Collimation:
"""
Expand Down Expand Up @@ -281,9 +280,6 @@ def as_h5(self, group: h5py.Group):
self.wavelength_spread.as_h5(group, "wavelength_spread")





@dataclass(kw_only=True)
class Sample:
"""
Expand Down Expand Up @@ -458,14 +454,13 @@ def to_string(self, header=""):
)
else:
attributes = ""
if self.contents:
if type(self.contents) is str:
children = f"\n{header} {self.contents}"
else:
match self.contents:
case list() | tuple():
children = "".join([n.to_string(header + " ") for n in self.contents])
else:
children = ""

case None:
children = ""
case _:
children = f"\n{header} {self.contents}"
return f"\n{header}{self.name}:{attributes}{children}"

def filter(self, name: str) -> list[ndarray | Quantity | str]:
Expand Down Expand Up @@ -573,7 +568,7 @@ def id_header(self):
title = ""
if self.title is not None:
title = self.title
return f"{title}:{",".join(self.run)}"
return f"{title}:{','.join(self.run)}"

def as_h5(self, f: h5py.Group):
"""Export data onto an HDF5 group"""
Expand Down
189 changes: 161 additions & 28 deletions sasdata/trend.py
Original file line number Diff line number Diff line change
@@ -1,48 +1,182 @@
import logging
from dataclasses import dataclass

import numpy as np

from sasdata.data import SasData
from sasdata.data_backing import Dataset, Group
from sasdata.quantities.quantity import Quantity
from sasdata.transforms.rebinning import calculate_interpolation_matrix_1d

logger = logging.getLogger(__name__)

# Axis strs refer to the name of their associated NamedQuantity.

# TODO: This probably shouldn't be here but will keep it here for now.

# TODO: This probably shouldn't be here but will keep it here for now. --> In sasdta/data.py?
# TODO: Similarity/relation to __getitem__ in SasData class?
# TODO: Or a method of Metadata class?
# TODO: Not sure how to type hint the return.
def get_metadatum_from_path(data: SasData, metadata_path: list[str]):
current_group = data._raw_metadata
current_node = data.metadata.raw
for path_item in metadata_path:
current_item = current_group.children.get(path_item, None)
if current_item is None or (isinstance(current_item, Dataset) and path_item != metadata_path[-1]):
raise ValueError('Path does not lead to valid a metadatum.')
elif isinstance(current_item, Group):
current_group = current_item
else:
return current_item.data
raise ValueError('End of path without finding a dataset.')
current_item = None

if isinstance(current_node.contents, list):
# Search through list of MetaNodes
for node in current_node.contents:
if node.name == path_item:
current_item = node
break

# If we did not find the item (either not a list or not found in list)
if current_item is None:
raise ValueError("Path does not lead to a valid metadatum.")

# Check if we're at the end of the path
if path_item == metadata_path[-1]:
return current_item.contents

current_node = current_item
raise ValueError("End of path without finding a dataset.")


@dataclass
class Trend:
data: list[SasData]
# This is going to be a path to a specific metadatum.
#
# TODO: But what if the trend axis will be a particular NamedQuantity? Will probably need to think on this.
trend_axis: list[str]

# Designed to take in a particular value of the trend axis, and return the SasData object that matches it.
# TODO: Not exaclty sure what item's type will be. It could depend on where it is pointing to.
def __getitem__(self, item) -> SasData:
for datum in self.data:
metadatum = get_metadatum_from_path(datum, self.trend_axis)
if metadatum == item:
return datum
raise KeyError()
trend_axes: dict[str, list[str] | list] # Path or manual values

def __post_init__(self):

# First, filter out invalid data items
self._filter_and_validate_data()

# Validate data length matches manual value lists
self._validate_manual_values()

# Validate metadata paths
self._validate_metadata_paths()

def _filter_and_validate_data(self):
"""Filter out non-SasData objects and validate data integrity"""
valid_data = []
invalid_indices = []

for i, datum in enumerate(self.data):
if not isinstance(datum, SasData):
invalid_indices.append(i)
continue

# Check if datum has metadata
if not hasattr(datum, "metadata") or datum.metadata is None:
invalid_indices.append(i)
continue

# Check if datum has raw metadata
if not hasattr(datum.metadata, "raw") or datum.metadata.raw is None:
invalid_indices.append(i)
continue

valid_data.append(datum)

# Update data with only valid items
self.data = valid_data

# Warn about filtered items
if invalid_indices:
logger.warning(
f"Warning: Removed data items at indices {invalid_indices} - not SasData objects or missing/invalid metadata"
)

# Additional validation
if not self.data:
raise ValueError("No valid data items remain after filtering")

if len(self.data) < 2:
logger.warning(f"Only {len(self.data)} valid data items remain")

# TODO: Decide if these limitations are ok or not (e.g. Should the user be able
# to specify manual values that are not numbers? Or have a different number of
# manual values than data items? How to assign the values then?, etc.)
def _validate_manual_values(self):
"""Ensure manual value lists are valid and match data length"""

for axis_name, axis_config in self.trend_axes.items():
# Only validate if this is a manual value axis (not a metadata path)
if isinstance(axis_config, list) and len(axis_config) > 0 and isinstance(axis_config[0], str):
# This is a metadata path, skip manual value validation
continue

if not isinstance(axis_config, list):
raise ValueError(
f"Manual values for axis '{axis_name}' should be passed as a list, got {type(axis_config).__name__}"
)

if len(axis_config) == 0:
raise ValueError(f"Manual values for axis '{axis_name}' must not be empty")

if not all(isinstance(v, (int, float)) for v in axis_config):
raise ValueError(f"All values for axis '{axis_name}' must be numbers (int or float)")

if len(axis_config) != len(self.data):
raise ValueError(
f"Manual values for axis '{axis_name}' must have same length as data "
f"({len(self.data)} items, got {len(axis_config)})"
)

def _validate_metadata_paths(self):
"""Validate metadata paths"""
for axis_name, axis_config in self.trend_axes.items():
if isinstance(axis_config, list) and len(axis_config) > 0 and isinstance(axis_config[0], str):
# This is a metadata path
for i, datum in enumerate(self.data):
try:
get_metadatum_from_path(datum, axis_config)
except ValueError as e:
raise ValueError(f"trend_axes['{axis_name}'] path {axis_config} invalid for data item {i}: {e}")

def get_trend_values(self, axis_name: str) -> list:
"""Get values for a named trend axis"""
if axis_name not in self.trend_axes:
raise KeyError(f"Axis '{axis_name}' not found")

axis_config = self.trend_axes[axis_name]

if isinstance(axis_config[0], str):
# Metadata path - extract from data
return [get_metadatum_from_path(datum, axis_config) for datum in self.data]
else:
# Manual values - return as-is
return axis_config.copy() # Return copy to prevent modification

def add_manual_axis(self, axis_name: str, values: list):
"""Add a new manual trend axis"""
if len(values) != len(self.data):
raise ValueError(f"Manual values must have same length as data ({len(self.data)} items, got {len(values)})")

self.trend_axes[axis_name] = values.copy()

def add_metadata_axis(self, axis_name: str, path: list[str]):
"""Add a new metadata trend axis"""
# Validate the path first
for i, datum in enumerate(self.data):
try:
get_metadatum_from_path(datum, path)
except ValueError as e:
raise ValueError(f"Path {path} invalid for data item {i}: {e}")

self.trend_axes[axis_name] = path

@property
def trend_axes(self) -> list[float]:
return [get_metadatum_from_path(datum, self.trend_axis) for datum in self.data]
def axis_names(self) -> list[str]:
return list(self.trend_axes.keys())

def is_manual_axis(self, axis_name: str) -> bool:
"""Check if an axis uses manual values or metadata path"""
if axis_name not in self.trend_axes:
raise KeyError(f"Axis '{axis_name}' not found")

axis_config = self.trend_axes[axis_name]
return not (isinstance(axis_config, list) and len(axis_config) > 0 and isinstance(axis_config[0], str))

# TODO: Assumes there are at least 2 items in data. Is this reasonable to assume? Should there be error handling for
# situations where this may not be the case?
Expand Down Expand Up @@ -84,6 +218,5 @@ def interpolate(self, axis: str) -> "Trend":
metadata=datum.metadata,
)
new_data.append(new_datum)
new_trend = Trend(new_data,
self.trend_axis)
new_trend = Trend(new_data, self.trend_axes)
return new_trend
Binary file not shown.
Loading
Loading