Repository: xarray-contrib/xeofs

Issue:
MCA incorrect coords alignment in transform method
**Describe the bug**
In the MCA transform method, the output does not have the consistent coordinates along the fit dimensions. The output is full of nan with incorrect coordinates.

**Reproducible Minimal Working Example**

```python
import xarray as xr
import pandas as pd
import numpy as np

lat = np.arange(-10, 11, 1) 
lon = np.arange(-20, 21, 1) 
onset1 = pd.date_range("2020-01-01", periods=10, freq="D")
onset2 = pd.date_range("2020-01-20", periods=10, freq="D") 

# Create random data
data_X_train = np.random.rand(len(lat), len(lon), len(onset1))
data_y_train = np.random.rand(len(lat), len(lon), len(onset1))
data_X_test = np.random.rand(len(lat), len(lon), len(onset2))

# Create xarray datasets
X_train = xr.DataArray(data_X_train, coords=[("latitude", lat), ("longitude", lon), ("onset", onset1)])
y_train = xr.DataArray(data_y_train, coords=[("latitude", lat), ("longitude", lon), ("onset", onset1)])
X_test = xr.DataArray(data_X_test, coords=[("latitude", lat), ("longitude", lon), ("onset", onset2)])

# MCA apply
mca = xeofs.models.MCA()
mca.fit(X_train,y_train,dim="onset")
mca.transform(X_test)
```
Obtained result is:
```
[<xarray.DataArray (mode: 2, onset: 10)>
 array([[nan, nan, nan, nan, nan, nan, nan, nan, nan, nan],
        [nan, nan, nan, nan, nan, nan, nan, nan, nan, nan]])
 Coordinates:
   * onset    (onset) datetime64[ns] 2020-01-01 2020-01-02 ... 2020-01-10
   * mode     (mode) int64 1 2]
```

**Expected behavior**
In this example, I would expect coordinates to go from 2020-01-20 and not from 2020-01-01. It seems that the output has kept the coordinates of the dataset used for fitting, while i would except the coordinates from the dataset to transform. It results in a dataset full of nans.

**Desktop (please complete the following information):**
 - OS: Ubuntu (WSL2)
 - `xeofs` version 2.3

Thanks for your help !

Discussion:
@nicrie: hey @agirnow, thank you for documenting that, it indeed looks like a bug. I'm quite packed this week, so I'll be able to take a closer look at it at the earliest over the weekend.@nicrie: version 2.3.1 was just released which should fix this issue. let me know @agirnow if it indeed solved the problem for you.

On a different note: from your example using train and test, I wonder if your goal is to make predictions of one field using the transformed scores of the other? Unfortunately, this is not yet implemented - the `transform` method will really just transform the provided field data into the scores of the latent space.@agirnow: Hey @nicrie 
Thanks, it solved my problem ! :)
Yes, that is exaclty what i wanted to do, I thought that to do so it will be okay to use the scores obtained with the transform method on the first field as an input for the inverse_transform method on the other field, but maybe that is not true.

Solution patch:
diff --git a/xeofs/models/_base_cross_model.py b/xeofs/models/_base_cross_model.py
index 7ecb886..90320ac 100644
--- a/xeofs/models/_base_cross_model.py
+++ b/xeofs/models/_base_cross_model.py
@@ -214,7 +214,15 @@ class _BaseCrossModel(ABC):
             # Preprocess data2
             data2 = self.preprocessor2.transform(data2)
 
-        return self._transform_algorithm(data1, data2)
+        data = self._transform_algorithm(data1, data2)
+        data_list = []
+        if data1 is not None:
+            data1 = self.preprocessor1.inverse_transform_scores_unseen(data["data1"])
+            data_list.append(data1)
+        if data2 is not None:
+            data2 = self.preprocessor2.inverse_transform_scores_unseen(data["data2"])
+            data_list.append(data2)
+        return data_list
 
     def inverse_transform(
         self, scores1: DataArray, scores2: DataArray
@@ -265,7 +273,7 @@ class _BaseCrossModel(ABC):
     @abstractmethod
     def _transform_algorithm(
         self, data1: Optional[DataArray] = None, data2: Optional[DataArray] = None
-    ) -> Sequence[DataArray]:
+    ) -> Dict[str, DataArray]:
         """
         Transform the preprocessed data. This method needs to be implemented in the respective
         subclass.
diff --git a/xeofs/models/mca.py b/xeofs/models/mca.py
index 3bb5922..6ab7da9 100644
--- a/xeofs/models/mca.py
+++ b/xeofs/models/mca.py
@@ -237,25 +237,21 @@ class MCA(_BaseCrossModel):
 
     def _transform_algorithm(
         self, data1: Optional[DataArray] = None, data2: Optional[DataArray] = None
-    ) -> Sequence[DataArray]:
-        results = []
+    ) -> Dict[str, DataArray]:
+        results = {}
         if data1 is not None:
             # Project data onto singular vectors
             comps1 = self.data["components1"]
             norm1 = self.data["norm1"]
             scores1 = xr.dot(data1, comps1) / norm1
-            # Inverse transform scores
-            scores1 = self.preprocessor1.inverse_transform_scores(scores1)
-            results.append(scores1)
+            results["data1"] = scores1
 
         if data2 is not None:
             # Project data onto singular vectors
             comps2 = self.data["components2"]
             norm2 = self.data["norm2"]
             scores2 = xr.dot(data2, comps2) / norm2
-            # Inverse transform scores
-            scores2 = self.preprocessor2.inverse_transform_scores(scores2)
-            results.append(scores2)
+            results["data2"] = scores2
 
         return results
 


Test patch:
diff --git a/tests/models/test_mca.py b/tests/models/test_mca.py
index 81e5649..579c787 100644
--- a/tests/models/test_mca.py
+++ b/tests/models/test_mca.py
@@ -106,6 +106,20 @@ def test_transform(mca_model, mock_data_array, dim):
     assert isinstance(result[0], xr.DataArray)
 
 
+@pytest.mark.parametrize("dim", [(("time",))])
+def test_transform_unseen_data(mca_model, mock_data_array, dim):
+    data = mock_data_array.isel(time=slice(0, 20))
+    data_unseen = mock_data_array.isel(time=slice(21, None))
+
+    mca_model.fit(data, data, dim)
+    result = mca_model.transform(data1=data_unseen, data2=data_unseen)
+    assert isinstance(result, list)
+    assert isinstance(result[0], xr.DataArray)
+    # Check that unseen data can be transformed
+    assert result[0].notnull().all()
+    assert result[1].notnull().all()
+
+
 @pytest.mark.parametrize(
     "dim",
     [