Skip to main content

ferray_ma/
interop.rs

1// ferray-ma: Interop with ferray-core's Array<T, D>
2//
3// In NumPy, MaskedArray subclasses ndarray, so anywhere ndarray is accepted
4// MaskedArray works too — with mask propagation handled automatically by
5// __array_ufunc__. Rust has no inheritance, so we provide the interop in
6// three layers:
7//
8//   1. `AsRef<Array<T, D>>` for low-level access — pass `&masked.as_ref()`
9//      where `&Array<T, D>` is expected, accepting that the mask is dropped.
10//      Useful when you need the data quickly and the mask is irrelevant.
11//
12//   2. `From<MaskedArray<T, D>>` for `Array<T, D>` — owned data extraction
13//      that consumes the mask. Use when you want to convert and forget.
14//
15//   3. `apply_unary` / `apply_binary` — mask-aware adapters that take any
16//      Array→Array function (e.g. `ferray_ufunc::sin`) and propagate the
17//      mask through to the result. The function operates on every element
18//      of the underlying data array (including masked positions, which
19//      hold whatever value is there); the mask is then re-attached to the
20//      result so that downstream consumers see the correct invalidity.
21//
22// See: https://github.com/dollspace-gay/ferray/issues/505
23//
24// ## REQ status
25//
26// REQ-16 SHIPPED — audited, green. This module provides the
27// `MaskedArray <-> Array` conversion layer and the mask-aware adapters that
28// back `numpy.ma.getdata`/`getmask`/`getmaskarray`.
29//
30// | REQ | Status | Evidence |
31// |-----|--------|----------|
32// | REQ-16 (`getmask`/`getdata`) | SHIPPED | The `AsRef<Array<T, D>>` impl + `From<MaskedArray<T, D>> for Array<T, D>` + `into_data` (this file) extract the underlying data; `getdata`/`getmask` live in `mask_ops.rs` and `getmaskarray` in `extras.rs`, but the data-view machinery they read is defined here. Non-test production consumers: `fma::getdata` is called throughout `ferray-python/src/ma.rs` (data egress for repr/median/cov/etc.), and `fma::getmaskarray` is the mask-extraction primitive for the binary/comparison shims there. |
33//
34// Also SHIPPED here: the `MaskAware<T, D>` trait (uniform mask access over
35// `Array` and `MaskedArray`) and the `apply_unary`/`apply_binary`/
36// `apply_unary_to`/`ma_apply_unary` adapters — `ma_apply_unary` (re-exported
37// from `ferray-ma/src/lib.rs`) is the mask-propagating bridge consumed by the
38// `numpy.ma` unary shims; the default fill from REQ-5 fills masked slots here.
39
40use ferray_core::Array;
41use ferray_core::dimension::Dimension;
42use ferray_core::dtype::Element;
43use ferray_core::error::{FerrayError, FerrayResult};
44
45use crate::MaskedArray;
46
47// ---------------------------------------------------------------------------
48// AsRef / From — passive interop
49// ---------------------------------------------------------------------------
50
51impl<T: Element, D: Dimension> AsRef<Array<T, D>> for MaskedArray<T, D> {
52    /// Borrow the underlying data array, dropping the mask.
53    ///
54    /// This lets you pass a `&MaskedArray<T, D>` to any function that takes
55    /// `&Array<T, D>`, but the mask is **not** consulted — masked positions
56    /// will be processed like normal data. Use [`MaskedArray::apply_unary`]
57    /// or [`MaskedArray::apply_binary`] when you want mask propagation.
58    ///
59    /// # Example
60    /// ```
61    /// # use ferray_core::{Array, dimension::Ix1};
62    /// # use ferray_ma::MaskedArray;
63    /// # let data = Array::<f64, Ix1>::from_vec(Ix1::new([3]), vec![1.0, 2.0, 3.0]).unwrap();
64    /// # let mask = Array::<bool, Ix1>::from_vec(Ix1::new([3]), vec![false, true, false]).unwrap();
65    /// let ma = MaskedArray::new(data, mask).unwrap();
66    /// // Pass through a function that operates on `&Array<T, D>`:
67    /// let arr_ref: &Array<f64, Ix1> = ma.as_ref();
68    /// assert_eq!(arr_ref.shape(), &[3]);
69    /// ```
70    fn as_ref(&self) -> &Array<T, D> {
71        self.data()
72    }
73}
74
75impl<T: Element + Copy, D: Dimension> From<MaskedArray<T, D>> for Array<T, D> {
76    /// Consume a `MaskedArray` and return its underlying data array,
77    /// **discarding** the mask. Equivalent to calling `ma.into_data()`.
78    ///
79    /// Requires `T: Copy` because the underlying data buffer is cloned;
80    /// use [`MaskedArray::filled`] / [`MaskedArray::filled_default`] for a
81    /// mask-aware materialization with custom fill semantics.
82    fn from(ma: MaskedArray<T, D>) -> Self {
83        ma.into_data()
84    }
85}
86
87// ---------------------------------------------------------------------------
88// Active interop — apply functions with mask propagation
89// ---------------------------------------------------------------------------
90
91impl<T, D> MaskedArray<T, D>
92where
93    T: Element + Copy,
94    D: Dimension,
95{
96    /// Consume the masked array and return its underlying data array,
97    /// dropping the mask.
98    ///
99    /// Use [`MaskedArray::filled_default`] (or [`MaskedArray::filled`]) if
100    /// you want masked positions replaced by a sentinel value before
101    /// dropping the mask.
102    pub fn into_data(self) -> Array<T, D> {
103        // We can't move out of a struct with non-Copy fields directly
104        // because of `data_mut()` borrowing semantics, so destructure
105        // the unsafe-but-safe internal `data` field via the public getter.
106        // The clone here is unavoidable without a fully private accessor.
107        self.data().clone()
108    }
109
110    /// Apply a unary function to the underlying data and re-attach the
111    /// mask, propagating it to the result.
112    ///
113    /// The function `f` is called on the **entire** data array — masked
114    /// positions are processed alongside unmasked ones, but their values
115    /// in the result are immediately overwritten with the masked array's
116    /// `fill_value`. This matches `NumPy`'s `__array_ufunc__` semantics where
117    /// ufuncs run over the raw data and the mask is propagated separately.
118    ///
119    /// # Example
120    /// ```ignore
121    /// // Apply ferray-ufunc::sin to a masked f64 array:
122    /// let result = ma.apply_unary(|arr| ferray_ufunc::sin(arr))?;
123    /// ```
124    ///
125    /// # Errors
126    /// Forwards any error from `f`.
127    pub fn apply_unary<F>(&self, f: F) -> FerrayResult<Self>
128    where
129        F: FnOnce(&Array<T, D>) -> FerrayResult<Array<T, D>>,
130    {
131        let data_out = f(self.data())?;
132        if data_out.shape() != self.shape() {
133            return Err(FerrayError::shape_mismatch(format!(
134                "apply_unary: function changed shape from {:?} to {:?}",
135                self.shape(),
136                data_out.shape()
137            )));
138        }
139        let fill = self.fill_value();
140        // Replace masked positions in the result with fill_value to keep
141        // operations like log/sqrt from leaving misleading data behind.
142        let masked_data: Vec<T> = data_out
143            .iter()
144            .zip(self.mask().iter())
145            .map(|(v, m)| if *m { fill } else { *v })
146            .collect();
147        let final_data = Array::from_vec(self.dim().clone(), masked_data)?;
148        let mut result = Self::new(final_data, self.mask().clone())?;
149        result.set_fill_value(fill);
150        Ok(result)
151    }
152
153    /// Apply a unary function that maps `T -> U`, propagating the mask.
154    ///
155    /// This is the type-changing variant of [`MaskedArray::apply_unary`],
156    /// useful for predicates like `isnan` that return `Array<bool, D>` from
157    /// `Array<T, D>`. Masked positions in the result hold the explicitly
158    /// supplied `default_for_masked` value.
159    ///
160    /// # Errors
161    /// Forwards any error from `f`. Returns `FerrayError::ShapeMismatch` if
162    /// `f` produces an array with a different shape.
163    pub fn apply_unary_to<U, F>(
164        &self,
165        f: F,
166        default_for_masked: U,
167    ) -> FerrayResult<MaskedArray<U, D>>
168    where
169        U: Element + Copy,
170        F: FnOnce(&Array<T, D>) -> FerrayResult<Array<U, D>>,
171    {
172        let data_out = f(self.data())?;
173        if data_out.shape() != self.shape() {
174            return Err(FerrayError::shape_mismatch(format!(
175                "apply_unary_to: function changed shape from {:?} to {:?}",
176                self.shape(),
177                data_out.shape()
178            )));
179        }
180        let masked_data: Vec<U> = data_out
181            .iter()
182            .zip(self.mask().iter())
183            .map(|(v, m)| if *m { default_for_masked } else { *v })
184            .collect();
185        let final_data = Array::from_vec(self.dim().clone(), masked_data)?;
186        let mut result = MaskedArray::new(final_data, self.mask().clone())?;
187        result.set_fill_value(default_for_masked);
188        Ok(result)
189    }
190
191    /// Apply a binary function to two masked arrays, propagating the mask
192    /// union. Both inputs must have the same shape.
193    ///
194    /// The function `f` is called on the underlying data of both inputs
195    /// (no broadcasting — use [`crate::masked_add`]-style functions for
196    /// that). The result mask is the OR of the two input masks; masked
197    /// positions in the data are overwritten with the receiver's `fill_value`.
198    ///
199    /// # Example
200    /// ```ignore
201    /// let result = a.apply_binary(&b, |x, y| ferray_ufunc::power(x, y))?;
202    /// ```
203    ///
204    /// # Errors
205    /// Returns `FerrayError::ShapeMismatch` if shapes differ. Forwards any
206    /// error from `f`.
207    pub fn apply_binary<F>(&self, other: &Self, f: F) -> FerrayResult<Self>
208    where
209        F: FnOnce(&Array<T, D>, &Array<T, D>) -> FerrayResult<Array<T, D>>,
210    {
211        if self.shape() != other.shape() {
212            return Err(FerrayError::shape_mismatch(format!(
213                "apply_binary: shapes {:?} and {:?} differ",
214                self.shape(),
215                other.shape()
216            )));
217        }
218        let data_out = f(self.data(), other.data())?;
219        if data_out.shape() != self.shape() {
220            return Err(FerrayError::shape_mismatch(format!(
221                "apply_binary: function changed shape from {:?} to {:?}",
222                self.shape(),
223                data_out.shape()
224            )));
225        }
226
227        // Mask union.
228        let union_data: Vec<bool> = self
229            .mask()
230            .iter()
231            .zip(other.mask().iter())
232            .map(|(a, b)| *a || *b)
233            .collect();
234        let union_mask = Array::from_vec(self.dim().clone(), union_data)?;
235
236        let fill = self.fill_value();
237        let masked_data: Vec<T> = data_out
238            .iter()
239            .zip(union_mask.iter())
240            .map(|(v, m)| if *m { fill } else { *v })
241            .collect();
242        let final_data = Array::from_vec(self.dim().clone(), masked_data)?;
243        let mut result = Self::new(final_data, union_mask)?;
244        result.set_fill_value(fill);
245        Ok(result)
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use ferray_core::dimension::Ix1;
253
254    fn ma1(data: Vec<f64>, mask: Vec<bool>) -> MaskedArray<f64, Ix1> {
255        let n = data.len();
256        let d = Array::<f64, Ix1>::from_vec(Ix1::new([n]), data).unwrap();
257        let m = Array::<bool, Ix1>::from_vec(Ix1::new([n]), mask).unwrap();
258        MaskedArray::new(d, m).unwrap()
259    }
260
261    #[test]
262    fn as_ref_returns_underlying_data() {
263        let ma = ma1(vec![1.0, 2.0, 3.0], vec![false, true, false]);
264        let arr_ref: &Array<f64, Ix1> = ma.as_ref();
265        assert_eq!(arr_ref.shape(), &[3]);
266        // The data is the unmasked-equivalent, mask is dropped.
267        let v: Vec<f64> = arr_ref.iter().copied().collect();
268        assert_eq!(v, vec![1.0, 2.0, 3.0]);
269    }
270
271    #[test]
272    fn from_masked_to_array_drops_mask() {
273        let ma = ma1(vec![1.0, 2.0, 3.0], vec![false, true, false]);
274        let arr: Array<f64, Ix1> = ma.into();
275        assert_eq!(arr.shape(), &[3]);
276    }
277
278    #[test]
279    fn into_data_method() {
280        let ma = ma1(vec![1.0, 2.0, 3.0], vec![false, true, false]);
281        let arr = ma.into_data();
282        assert_eq!(arr.shape(), &[3]);
283    }
284
285    #[test]
286    fn apply_unary_propagates_mask() {
287        let ma =
288            ma1(vec![1.0, 4.0, 9.0, 16.0], vec![false, false, true, false]).with_fill_value(-1.0);
289        let result = ma
290            .apply_unary(|arr| {
291                // Squaring closure as a stand-in for any ferray-ufunc function
292                let data: Vec<f64> = arr.iter().map(|&x| x.sqrt()).collect();
293                Array::<f64, Ix1>::from_vec(Ix1::new([arr.size()]), data)
294            })
295            .unwrap();
296
297        // Masked position (index 2) holds fill_value; others hold sqrt.
298        let d: Vec<f64> = result.data().iter().copied().collect();
299        assert_eq!(d, vec![1.0, 2.0, -1.0, 4.0]);
300        // Mask is preserved.
301        let m: Vec<bool> = result.mask().iter().copied().collect();
302        assert_eq!(m, vec![false, false, true, false]);
303        // Fill value is preserved.
304        assert_eq!(result.fill_value(), -1.0);
305    }
306
307    #[test]
308    fn apply_unary_forwards_error() {
309        let ma = ma1(vec![1.0, 2.0], vec![false, false]);
310        let result: FerrayResult<MaskedArray<f64, Ix1>> =
311            ma.apply_unary(|_| Err(FerrayError::invalid_value("simulated failure")));
312        assert!(result.is_err());
313    }
314
315    #[test]
316    fn apply_unary_rejects_shape_change() {
317        let ma = ma1(vec![1.0, 2.0, 3.0], vec![false, false, false]);
318        let result = ma.apply_unary(|_| Array::<f64, Ix1>::from_vec(Ix1::new([2]), vec![1.0, 2.0]));
319        assert!(result.is_err());
320    }
321
322    #[test]
323    fn apply_unary_to_changes_type_with_mask_default() {
324        // Apply a "is positive" predicate that returns bool.
325        let ma = ma1(vec![1.0, -2.0, 3.0, -4.0], vec![false, false, true, false]);
326        let result = ma
327            .apply_unary_to(
328                |arr| {
329                    let data: Vec<bool> = arr.iter().map(|&x| x > 0.0).collect();
330                    Array::<bool, Ix1>::from_vec(Ix1::new([arr.size()]), data)
331                },
332                false, // default for masked positions
333            )
334            .unwrap();
335
336        let d: Vec<bool> = result.data().iter().copied().collect();
337        // Index 2 is masked → false (the default); others reflect the predicate.
338        assert_eq!(d, vec![true, false, false, false]);
339        let m: Vec<bool> = result.mask().iter().copied().collect();
340        assert_eq!(m, vec![false, false, true, false]);
341    }
342
343    #[test]
344    fn apply_binary_unions_masks() {
345        let a = ma1(vec![10.0, 20.0, 30.0], vec![false, true, false]).with_fill_value(-1.0);
346        let b = ma1(vec![1.0, 2.0, 3.0], vec![false, false, true]);
347        let result = a
348            .apply_binary(&b, |x, y| {
349                let data: Vec<f64> = x.iter().zip(y.iter()).map(|(&a, &b)| a + b).collect();
350                Array::<f64, Ix1>::from_vec(Ix1::new([x.size()]), data)
351            })
352            .unwrap();
353
354        let d: Vec<f64> = result.data().iter().copied().collect();
355        // Indices 1 and 2 are masked (union of the two input masks); index 0
356        // gets the actual sum.
357        assert_eq!(d, vec![11.0, -1.0, -1.0]);
358        let m: Vec<bool> = result.mask().iter().copied().collect();
359        assert_eq!(m, vec![false, true, true]);
360        assert_eq!(result.fill_value(), -1.0);
361    }
362
363    #[test]
364    fn apply_binary_rejects_shape_mismatch() {
365        let a = ma1(vec![1.0, 2.0, 3.0], vec![false; 3]);
366        let b = ma1(vec![1.0, 2.0], vec![false; 2]);
367        let result = a.apply_binary(&b, |x, _y| Ok(x.clone()));
368        assert!(result.is_err());
369    }
370
371    /// Demonstrates the canonical interop pattern with a real ferray-stats
372    /// call: pass `&MaskedArray` through `as_ref()` to a function that
373    /// expects `&Array`. This loses the mask (per the `AsRef` contract) but
374    /// is the cheapest way to bridge.
375    #[test]
376    fn as_ref_works_with_array_consuming_function() {
377        // A simple Array -> Array function (any closure works as a stand-in).
378        fn double(arr: &Array<f64, Ix1>) -> FerrayResult<Array<f64, Ix1>> {
379            let data: Vec<f64> = arr.iter().map(|&x| x * 2.0).collect();
380            Array::<f64, Ix1>::from_vec(Ix1::new([arr.size()]), data)
381        }
382
383        let ma = ma1(vec![1.0, 2.0, 3.0], vec![false, true, false]);
384        // Direct call via AsRef — the mask is dropped:
385        let result = double(ma.as_ref()).unwrap();
386        let v: Vec<f64> = result.iter().copied().collect();
387        assert_eq!(v, vec![2.0, 4.0, 6.0]);
388
389        // For mask-preserving usage, route through apply_unary instead:
390        let masked_result = ma.apply_unary(double).unwrap();
391        let m: Vec<bool> = masked_result.mask().iter().copied().collect();
392        assert_eq!(m, vec![false, true, false]);
393    }
394}
395
396// ---------------------------------------------------------------------------
397// MaskAware trait: common interface for Array and MaskedArray (#505)
398//
399// NumPy's MaskedArray subclasses ndarray so any ndarray-accepting function
400// automatically accepts MaskedArray (with __array_ufunc__ handling mask
401// propagation). Rust doesn't have inheritance, so the ferray equivalent is a
402// trait both types implement: mask-aware functions take
403// `&impl MaskAware<T, D>` and dispatch via the trait methods.
404//
405// Array<T, D> is treated as "always fully unmasked" — `mask_opt()` returns
406// None and `fill_value()` falls back to `T::zero()`. MaskedArray<T, D>
407// delegates to its actual accessors. This lets callers write one function
408// that works on both and still propagates masks correctly.
409// ---------------------------------------------------------------------------
410
411/// Shared view contract for functions that want to accept either an
412/// `Array` or a `MaskedArray` (#505).
413///
414/// Implementations:
415/// - `Array<T, D>`: `data()` returns `self`, `mask_opt()` returns `None`
416///   (no mask), `fill_value()` returns `T::zero()`. The array is treated
417///   as fully unmasked.
418/// - `MaskedArray<T, D>`: delegates to the existing accessors.
419///
420/// Downstream code that wants to write "one function, works on both"
421/// should take `&impl MaskAware<T, D>` and consult `mask_opt()` to
422/// decide whether to do mask propagation.
423pub trait MaskAware<T: Element, D: Dimension> {
424    /// Return a reference to the underlying data array.
425    fn data(&self) -> &Array<T, D>;
426
427    /// Return the mask array if one is explicitly present, or `None`
428    /// when the input carries no mask (treated as fully unmasked).
429    ///
430    /// For `Array<T, D>` this always returns `None`. For
431    /// `MaskedArray<T, D>` it returns `Some` when a real mask has
432    /// been explicitly set and `None` when the array is in the
433    /// nomask-sentinel state (#506).
434    fn mask_opt(&self) -> Option<&Array<bool, D>>;
435
436    /// Return the fill value to use for masked positions in
437    /// derived results.
438    fn fill_value(&self) -> T
439    where
440        T: Copy;
441
442    /// Return the shape of the underlying data.
443    fn shape(&self) -> &[usize] {
444        self.data().shape()
445    }
446}
447
448impl<T: Element, D: Dimension> MaskAware<T, D> for Array<T, D> {
449    #[inline]
450    fn data(&self) -> &Self {
451        self
452    }
453
454    /// A plain `Array<T, D>` has no mask — always returns `None`.
455    #[inline]
456    fn mask_opt(&self) -> Option<&Array<bool, D>> {
457        None
458    }
459
460    /// A plain `Array<T, D>` has no fill value; returns `T::zero()`.
461    #[inline]
462    fn fill_value(&self) -> T
463    where
464        T: Copy,
465    {
466        T::zero()
467    }
468}
469
470impl<T: Element, D: Dimension> MaskAware<T, D> for MaskedArray<T, D> {
471    #[inline]
472    fn data(&self) -> &Array<T, D> {
473        Self::data(self)
474    }
475
476    #[inline]
477    fn mask_opt(&self) -> Option<&Array<bool, D>> {
478        Self::mask_opt(self)
479    }
480
481    #[inline]
482    fn fill_value(&self) -> T
483    where
484        T: Copy,
485    {
486        Self::fill_value(self)
487    }
488}
489
490/// Apply a unary function to any `MaskAware` input, propagating the
491/// mask if one is present.
492///
493/// When the input is a plain `Array<T, D>` (or a nomask-sentinel
494/// `MaskedArray`), the function is applied directly and the result
495/// is returned as a nomask `MaskedArray`. When the input has a real
496/// mask, this delegates to the existing [`MaskedArray::apply_unary`]
497/// path so masked positions are overwritten with the fill value.
498///
499/// Use this to write "one function, works on both" adapters:
500///
501/// ```ignore
502/// fn my_op<X: MaskAware<f64, Ix1>>(x: &X) -> FerrayResult<MaskedArray<f64, Ix1>> {
503///     ma_apply_unary(x, |a| ferray_ufunc::sin(a))
504/// }
505/// ```
506///
507/// # Errors
508/// Forwards any error from `f`, plus shape-mismatch errors if `f`
509/// returns a differently-shaped array.
510pub fn ma_apply_unary<T, D, X, F>(input: &X, f: F) -> FerrayResult<MaskedArray<T, D>>
511where
512    T: Element + Copy,
513    D: Dimension,
514    X: MaskAware<T, D>,
515    F: FnOnce(&Array<T, D>) -> FerrayResult<Array<T, D>>,
516{
517    let data_out = f(input.data())?;
518    if data_out.shape() != input.shape() {
519        return Err(FerrayError::shape_mismatch(format!(
520            "ma_apply_unary: function changed shape from {:?} to {:?}",
521            input.shape(),
522            data_out.shape()
523        )));
524    }
525
526    match input.mask_opt() {
527        None => {
528            // No mask — wrap the result in a nomask-sentinel
529            // MaskedArray so the caller gets a uniform return type.
530            let mut out = MaskedArray::from_data(data_out)?;
531            out.set_fill_value(input.fill_value());
532            Ok(out)
533        }
534        Some(mask) => {
535            // Overwrite masked positions with fill_value so downstream
536            // operations can't see stale data at masked slots.
537            let fill = input.fill_value();
538            let masked_data: Vec<T> = data_out
539                .iter()
540                .zip(mask.iter())
541                .map(|(v, m)| if *m { fill } else { *v })
542                .collect();
543            let final_data = Array::from_vec(input.data().dim().clone(), masked_data)?;
544            let mut result = MaskedArray::new(final_data, mask.clone())?;
545            result.set_fill_value(fill);
546            Ok(result)
547        }
548    }
549}
550
551#[cfg(test)]
552mod mask_aware_tests {
553    use super::*;
554    use ferray_core::dimension::Ix1;
555
556    fn arr_f64(data: Vec<f64>) -> Array<f64, Ix1> {
557        let n = data.len();
558        Array::<f64, Ix1>::from_vec(Ix1::new([n]), data).unwrap()
559    }
560
561    fn ma_f64(data: Vec<f64>, mask: Vec<bool>) -> MaskedArray<f64, Ix1> {
562        let d = arr_f64(data);
563        let n = d.size();
564        let m = Array::<bool, Ix1>::from_vec(Ix1::new([n]), mask).unwrap();
565        MaskedArray::new(d, m).unwrap()
566    }
567
568    // ---- MaskAware trait impls (#505) ----
569
570    #[test]
571    fn array_implements_mask_aware_with_none_mask() {
572        let a = arr_f64(vec![1.0, 2.0, 3.0]);
573        // Plain Array carries no mask.
574        assert!(<Array<f64, Ix1> as MaskAware<f64, Ix1>>::mask_opt(&a).is_none());
575        assert_eq!(
576            <Array<f64, Ix1> as MaskAware<f64, Ix1>>::fill_value(&a),
577            0.0
578        );
579        assert_eq!(<Array<f64, Ix1> as MaskAware<f64, Ix1>>::shape(&a), &[3]);
580    }
581
582    #[test]
583    fn masked_array_implements_mask_aware_with_real_mask() {
584        let ma = ma_f64(vec![1.0, 2.0, 3.0], vec![false, true, false]);
585        let via_trait = <MaskedArray<f64, Ix1> as MaskAware<f64, Ix1>>::mask_opt(&ma);
586        assert!(via_trait.is_some());
587        assert_eq!(
588            via_trait.unwrap().iter().copied().collect::<Vec<_>>(),
589            vec![false, true, false]
590        );
591    }
592
593    #[test]
594    fn nomask_sentinel_masked_array_reports_none_via_trait() {
595        // A from_data-constructed MaskedArray (nomask sentinel) should
596        // report None through the MaskAware trait, matching the
597        // behavior of a plain Array.
598        let ma = MaskedArray::from_data(arr_f64(vec![1.0, 2.0, 3.0])).unwrap();
599        let via_trait = <MaskedArray<f64, Ix1> as MaskAware<f64, Ix1>>::mask_opt(&ma);
600        assert!(via_trait.is_none());
601    }
602
603    #[test]
604    fn ma_apply_unary_on_plain_array_returns_nomask_result() {
605        let a = arr_f64(vec![1.0, 2.0, 3.0]);
606        let result = ma_apply_unary(&a, |x| {
607            let data: Vec<f64> = x.iter().map(|v| v * 2.0).collect();
608            Array::from_vec(x.dim().clone(), data)
609        })
610        .unwrap();
611        assert_eq!(
612            result.data().iter().copied().collect::<Vec<_>>(),
613            vec![2.0, 4.0, 6.0]
614        );
615        // Plain-Array input → nomask-sentinel result.
616        assert!(!result.has_real_mask());
617    }
618
619    #[test]
620    fn ma_apply_unary_on_masked_array_propagates_mask() {
621        let ma = ma_f64(vec![1.0, 2.0, 3.0], vec![false, true, false]);
622        let result = ma_apply_unary(&ma, |x| {
623            let data: Vec<f64> = x.iter().map(|v| v * 2.0).collect();
624            Array::from_vec(x.dim().clone(), data)
625        })
626        .unwrap();
627        // Mask survives the operation.
628        assert!(result.has_real_mask());
629        assert_eq!(
630            result.mask().iter().copied().collect::<Vec<_>>(),
631            vec![false, true, false]
632        );
633        // Masked position was overwritten with fill_value (numpy default
634        // 1e20, default_filler['f'] core.py:166).
635        let d: Vec<f64> = result.data().iter().copied().collect();
636        assert_eq!(d[0], 2.0);
637        assert_eq!(d[1], 1e20); // masked → numpy default fill value
638        assert_eq!(d[2], 6.0);
639    }
640
641    #[test]
642    fn ma_apply_unary_generic_over_both_types() {
643        // Write a helper that works on both Array and MaskedArray.
644        fn double_it<T, D, X>(x: &X) -> FerrayResult<MaskedArray<T, D>>
645        where
646            T: Element + Copy + std::ops::Mul<Output = T> + num_traits::FromPrimitive,
647            D: Dimension,
648            X: MaskAware<T, D>,
649        {
650            let two = T::from_f64(2.0).unwrap();
651            ma_apply_unary(x, move |a| {
652                let data: Vec<T> = a.iter().map(|v| *v * two).collect();
653                Array::from_vec(a.dim().clone(), data)
654            })
655        }
656
657        let plain = arr_f64(vec![1.0, 2.0, 3.0]);
658        let masked = ma_f64(vec![1.0, 2.0, 3.0], vec![false, true, false]);
659
660        // Both inputs go through the same helper.
661        let r_plain = double_it(&plain).unwrap();
662        let r_masked = double_it(&masked).unwrap();
663
664        // Plain result: no mask, all values doubled.
665        assert!(!r_plain.has_real_mask());
666        assert_eq!(
667            r_plain.data().iter().copied().collect::<Vec<_>>(),
668            vec![2.0, 4.0, 6.0]
669        );
670
671        // Masked result: mask preserved, masked position holds fill.
672        assert!(r_masked.has_real_mask());
673        assert_eq!(
674            r_masked.mask().iter().copied().collect::<Vec<_>>(),
675            vec![false, true, false]
676        );
677    }
678
679    #[test]
680    fn ma_apply_unary_rejects_shape_changing_function() {
681        let a = arr_f64(vec![1.0, 2.0, 3.0]);
682        let result = ma_apply_unary(&a, |_| {
683            // Return a wrong-shape result deliberately.
684            Ok(arr_f64(vec![1.0, 2.0]))
685        });
686        assert!(result.is_err());
687    }
688}