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