Skip to main content

ferray_core/manipulation/
mod.rs

1// ferray-core: Shape manipulation functions (REQ-20, REQ-21, REQ-22)
2//
3// Mirrors numpy's shape manipulation routines: reshape, ravel, flatten,
4// concatenate, stack, transpose, flip, etc.
5//
6// ## REQ status (shape manipulation, NumPy parity)
7//  - REQ-20 (shape methods) — SHIPPED: `reshape`/`ravel`/`flatten`/`squeeze`/
8//    `expand_dims`/`broadcast_to` (this file; `broadcast_to` re-exports the
9//    `dimension::broadcast` primitive). Non-test consumer: `array/arc.rs`
10//    materializes via `manipulation::broadcast_to`.
11//  - REQ-21 (join/split) — SHIPPED: `concatenate`/`stack`/`vstack`/`hstack`/
12//    `dstack`/`column_stack`/`row_stack`/`block`/`split`/`array_split`/
13//    `array_split_n`/`vsplit`/`hsplit`/`dsplit` (this file).
14//  - REQ-22 (transpose/reorder) — SHIPPED: `transpose`/`swapaxes`/`moveaxis`/
15//    `rollaxis`/`flip`/`fliplr`/`flipud`/`rot90`/`roll` (this file). Non-test
16//    consumer: `array/arc.rs` calls `manipulation::transpose`.
17//  - `r_`/`c_` (NumPy index-expression concatenation builders) — SHIPPED:
18//    `r_`/`c_` (this file).
19
20pub mod extended;
21
22use crate::array::owned::Array;
23use crate::dimension::{Dimension, Ix1, IxDyn};
24use crate::dtype::Element;
25use crate::error::{FerrayError, FerrayResult};
26
27// ============================================================================
28// REQ-20: Shape methods
29// ============================================================================
30
31/// Reshape an array to a new shape (returns a new owned array).
32///
33/// The total number of elements must remain the same.
34///
35/// Analogous to `numpy.reshape()`. Delegates to ndarray's `to_shape`,
36/// which re-uses the existing buffer (zero copy on the data side) for
37/// contiguous inputs and only does a bulk memcpy via `to_owned` — the
38/// old path ran an element-by-element `iter().cloned().collect()`
39/// instead (issue #82).
40///
41/// # Errors
42/// Returns `FerrayError::ShapeMismatch` if the new shape has a different
43/// total number of elements.
44pub fn reshape<T: Element, D: Dimension>(
45    a: &Array<T, D>,
46    new_shape: &[usize],
47) -> FerrayResult<Array<T, IxDyn>> {
48    let old_size = a.size();
49    let new_size: usize = new_shape.iter().product();
50    if old_size != new_size {
51        return Err(FerrayError::shape_mismatch(format!(
52            "cannot reshape array of size {old_size} into shape {new_shape:?} (size {new_size})",
53        )));
54    }
55    let view = a.inner.view().into_dyn();
56    let reshaped = view
57        .to_shape(ndarray::IxDyn(new_shape))
58        .map_err(|e| FerrayError::shape_mismatch(e.to_string()))?;
59    // `as_standard_layout` ensures the final buffer is C-contiguous so
60    // `as_slice()` works for callers, even when the source view was
61    // F-contiguous / strided.
62    Ok(Array::from_ndarray(
63        reshaped.as_standard_layout().into_owned(),
64    ))
65}
66
67/// Return a flattened (1-D) copy of the array.
68///
69/// Analogous to `numpy.ravel()`. Uses ndarray's `to_shape` rather than
70/// the element-wise collect path (issue #82).
71pub fn ravel<T: Element, D: Dimension>(a: &Array<T, D>) -> FerrayResult<Array<T, Ix1>> {
72    let n = a.size();
73    let view = a.inner.view().into_dyn();
74    let reshaped = view
75        .to_shape(ndarray::IxDyn(&[n]))
76        .expect("1-D reshape always succeeds for a size-preserving target");
77    let standard = reshaped.as_standard_layout().into_owned();
78    let one_d = standard
79        .into_dimensionality::<ndarray::Ix1>()
80        .expect("reshape result has ndim == 1 by construction");
81    Ok(Array::from_ndarray(one_d))
82}
83
84/// Return a flattened (1-D) copy of the array.
85///
86/// Identical to `ravel()` — analogous to `ndarray.flatten()`.
87pub fn flatten<T: Element, D: Dimension>(a: &Array<T, D>) -> FerrayResult<Array<T, Ix1>> {
88    ravel(a)
89}
90
91/// Remove axes of length 1 from the shape.
92///
93/// If `axis` is `None`, all length-1 axes are removed.
94/// If `axis` is `Some(ax)`, only that axis is removed (errors if it is not length 1).
95///
96/// Analogous to `numpy.squeeze()`.
97///
98/// # Errors
99/// Returns `FerrayError::AxisOutOfBounds` if the axis is invalid, or
100/// `FerrayError::InvalidValue` if the specified axis has size != 1.
101pub fn squeeze<T: Element, D: Dimension>(
102    a: &Array<T, D>,
103    axis: Option<usize>,
104) -> FerrayResult<Array<T, IxDyn>> {
105    let shape = a.shape();
106    if let Some(ax) = axis {
107        if ax >= shape.len() {
108            return Err(FerrayError::axis_out_of_bounds(ax, shape.len()));
109        }
110        if shape[ax] != 1 {
111            return Err(FerrayError::invalid_value(format!(
112                "cannot select axis {} with size {} for squeeze (must be 1)",
113                ax, shape[ax],
114            )));
115        }
116        let new_shape: Vec<usize> = shape
117            .iter()
118            .enumerate()
119            .filter(|&(i, _)| i != ax)
120            .map(|(_, &s)| s)
121            .collect();
122        let data: Vec<T> = a.iter().cloned().collect();
123        Array::from_vec(IxDyn::new(&new_shape), data)
124    } else {
125        // Remove every length-1 axis. When *all* axes are length 1 the
126        // result collapses to a 0-D array (shape `[]`), matching numpy:
127        // `np.squeeze(np.ones((1,1,1))).shape == ()` — numpy
128        // _core/fromnumeric.py:1615 ("if all axes are squeezed, the result
129        // is a 0d array and not a scalar"). An empty `new_shape` already
130        // encodes the 0-D case, so no special-casing is needed.
131        let new_shape: Vec<usize> = shape.iter().copied().filter(|&s| s != 1).collect();
132        let data: Vec<T> = a.iter().cloned().collect();
133        Array::from_vec(IxDyn::new(&new_shape), data)
134    }
135}
136
137/// Insert a new axis of length 1 at the given position.
138///
139/// Analogous to `numpy.expand_dims()`.
140///
141/// # Errors
142/// Returns `FerrayError::AxisOutOfBounds` if `axis > ndim`.
143pub fn expand_dims<T: Element, D: Dimension>(
144    a: &Array<T, D>,
145    axis: usize,
146) -> FerrayResult<Array<T, IxDyn>> {
147    let ndim = a.ndim();
148    if axis > ndim {
149        return Err(FerrayError::axis_out_of_bounds(axis, ndim + 1));
150    }
151    let mut new_shape: Vec<usize> = a.shape().to_vec();
152    new_shape.insert(axis, 1);
153    let data: Vec<T> = a.iter().cloned().collect();
154    Array::from_vec(IxDyn::new(&new_shape), data)
155}
156
157/// Broadcast an array to a new shape (returns a new owned array).
158///
159/// The array is replicated along size-1 dimensions to match the target shape.
160///
161/// Analogous to `numpy.broadcast_to()`. Uses ndarray's `broadcast` to
162/// produce a zero-copy stride-0 view and then materializes it with a
163/// single `to_owned` — the prior implementation allocated a source
164/// buffer via `iter().cloned().collect()` and walked every output
165/// index in a hand-rolled nested loop (issue #81).
166///
167/// # Errors
168/// Returns `FerrayError::BroadcastFailure` if the shapes are incompatible.
169pub fn broadcast_to<T: Element, D: Dimension>(
170    a: &Array<T, D>,
171    new_shape: &[usize],
172) -> FerrayResult<Array<T, IxDyn>> {
173    let src_shape = a.shape();
174    let dyn_view = a.inner.view().into_dyn();
175    let broadcast_view = dyn_view
176        .broadcast(ndarray::IxDyn(new_shape))
177        .ok_or_else(|| FerrayError::BroadcastFailure {
178            shape_a: src_shape.to_vec(),
179            shape_b: new_shape.to_vec(),
180        })?;
181    // `as_standard_layout` turns the stride-0 broadcast view into a
182    // proper C-contiguous owned buffer in one pass; the previous
183    // hand-rolled loop walked every destination index separately.
184    Ok(Array::from_ndarray(
185        broadcast_view.as_standard_layout().into_owned(),
186    ))
187}
188
189// ============================================================================
190// REQ-21: Join/split
191// ============================================================================
192
193/// Join a sequence of arrays along an existing axis.
194///
195/// Analogous to `numpy.concatenate()`.
196///
197/// # Errors
198/// Returns `FerrayError::InvalidValue` if the array list is empty.
199/// Returns `FerrayError::ShapeMismatch` if shapes differ on non-concatenation axes.
200/// Returns `FerrayError::AxisOutOfBounds` if axis is out of bounds.
201pub fn concatenate<T: Element>(
202    arrays: &[Array<T, IxDyn>],
203    axis: usize,
204) -> FerrayResult<Array<T, IxDyn>> {
205    if arrays.is_empty() {
206        return Err(FerrayError::invalid_value(
207            "concatenate: need at least one array",
208        ));
209    }
210    let ndim = arrays[0].ndim();
211    if axis >= ndim {
212        return Err(FerrayError::axis_out_of_bounds(axis, ndim));
213    }
214    let base_shape = arrays[0].shape();
215
216    // Validate all arrays have same ndim and matching shapes on non-concat axes
217    let mut total_along_axis = 0usize;
218    for arr in arrays {
219        if arr.ndim() != ndim {
220            return Err(FerrayError::shape_mismatch(format!(
221                "all arrays must have same ndim; got {} and {}",
222                ndim,
223                arr.ndim(),
224            )));
225        }
226        for (i, (&s, &base)) in arr.shape().iter().zip(base_shape.iter()).enumerate() {
227            if i != axis && s != base {
228                return Err(FerrayError::shape_mismatch(format!(
229                    "shape mismatch on axis {i}: {s} vs {base}",
230                )));
231            }
232        }
233        total_along_axis += arr.shape()[axis];
234    }
235
236    // Build new shape
237    let mut new_shape = base_shape.to_vec();
238    new_shape[axis] = total_along_axis;
239    let total: usize = new_shape.iter().product();
240    let mut data = Vec::with_capacity(total);
241
242    // Pre-collect each source array into a contiguous Vec to avoid O(n) iter().nth() per element
243    let src_vecs: Vec<Vec<T>> = arrays.iter().map(|a| a.iter().cloned().collect()).collect();
244
245    // Compute strides for the output array (C-order)
246    let mut out_strides = vec![1usize; ndim];
247    for i in (0..ndim - 1).rev() {
248        out_strides[i] = out_strides[i + 1] * new_shape[i + 1];
249    }
250
251    // For each position in the output, figure out which source array and offset
252    for flat_idx in 0..total {
253        // Convert flat index to nd-index
254        let mut rem = flat_idx;
255        let mut nd_idx = vec![0usize; ndim];
256        for i in 0..ndim {
257            nd_idx[i] = rem / out_strides[i];
258            rem %= out_strides[i];
259        }
260
261        // Find which source array this position belongs to
262        let concat_idx = nd_idx[axis];
263        let mut offset = 0;
264        let mut src_arr_idx = 0;
265        for (k, arr) in arrays.iter().enumerate() {
266            let len_along = arr.shape()[axis];
267            if concat_idx < offset + len_along {
268                src_arr_idx = k;
269                break;
270            }
271            offset += len_along;
272        }
273        let local_concat_idx = concat_idx - offset;
274
275        // Build source flat index (C-order)
276        let src_shape = arrays[src_arr_idx].shape();
277        let mut src_flat = 0usize;
278        let mut src_mul = 1usize;
279        for i in (0..ndim).rev() {
280            let idx = if i == axis {
281                local_concat_idx
282            } else {
283                nd_idx[i]
284            };
285            src_flat += idx * src_mul;
286            src_mul *= src_shape[i];
287        }
288
289        let elem = src_vecs[src_arr_idx].get(src_flat).ok_or_else(|| {
290            FerrayError::invalid_value(format!(
291                "concatenate: internal index {} out of range for source array of length {}",
292                src_flat,
293                src_vecs[src_arr_idx].len(),
294            ))
295        })?;
296        data.push(elem.clone());
297    }
298
299    Array::from_vec(IxDyn::new(&new_shape), data)
300}
301
302/// Join a sequence of arrays along a **new** axis.
303///
304/// All arrays must have the same shape. The result has one more dimension
305/// than the inputs.
306///
307/// Analogous to `numpy.stack()`.
308///
309/// # Errors
310/// Returns `FerrayError::InvalidValue` if the array list is empty.
311/// Returns `FerrayError::ShapeMismatch` if shapes differ.
312/// Returns `FerrayError::AxisOutOfBounds` if axis > ndim.
313pub fn stack<T: Element>(arrays: &[Array<T, IxDyn>], axis: usize) -> FerrayResult<Array<T, IxDyn>> {
314    if arrays.is_empty() {
315        return Err(FerrayError::invalid_value("stack: need at least one array"));
316    }
317    let base_shape = arrays[0].shape();
318    let ndim = base_shape.len();
319
320    if axis > ndim {
321        return Err(FerrayError::axis_out_of_bounds(axis, ndim + 1));
322    }
323
324    for arr in &arrays[1..] {
325        if arr.shape() != base_shape {
326            return Err(FerrayError::shape_mismatch(format!(
327                "all input arrays must have the same shape; got {:?} and {:?}",
328                base_shape,
329                arr.shape(),
330            )));
331        }
332    }
333
334    // Expand each array along the new axis, then concatenate
335    let mut expanded = Vec::with_capacity(arrays.len());
336    for arr in arrays {
337        expanded.push(expand_dims(arr, axis)?);
338    }
339    concatenate(&expanded, axis)
340}
341
342/// Stack arrays vertically (row-wise). Equivalent to `concatenate` along axis 0
343/// for 2-D+ arrays, or equivalent to stacking 1-D arrays as rows.
344///
345/// Analogous to `numpy.vstack()`.
346pub fn vstack<T: Element>(arrays: &[Array<T, IxDyn>]) -> FerrayResult<Array<T, IxDyn>> {
347    if arrays.is_empty() {
348        return Err(FerrayError::invalid_value(
349            "vstack: need at least one array",
350        ));
351    }
352    // For 1-D arrays, reshape to (1, N) then concatenate along axis 0
353    let ndim = arrays[0].ndim();
354    if ndim == 1 {
355        let mut reshaped = Vec::with_capacity(arrays.len());
356        for arr in arrays {
357            let n = arr.shape()[0];
358            reshaped.push(reshape(arr, &[1, n])?);
359        }
360        concatenate(&reshaped, 0)
361    } else {
362        concatenate(arrays, 0)
363    }
364}
365
366/// Stack arrays horizontally (column-wise). Equivalent to `concatenate` along
367/// axis 1 for 2-D+ arrays, or along axis 0 for 1-D arrays.
368///
369/// Analogous to `numpy.hstack()`.
370pub fn hstack<T: Element>(arrays: &[Array<T, IxDyn>]) -> FerrayResult<Array<T, IxDyn>> {
371    if arrays.is_empty() {
372        return Err(FerrayError::invalid_value(
373            "hstack: need at least one array",
374        ));
375    }
376    let ndim = arrays[0].ndim();
377    if ndim == 1 {
378        concatenate(arrays, 0)
379    } else {
380        concatenate(arrays, 1)
381    }
382}
383
384/// Stack arrays along the third axis (depth-wise).
385///
386/// For 1-D arrays of shape `(N,)`, reshapes to `(1, N, 1)`.
387/// For 2-D arrays of shape `(M, N)`, reshapes to `(M, N, 1)`.
388/// Then concatenates along axis 2.
389///
390/// Analogous to `numpy.dstack()`.
391pub fn dstack<T: Element>(arrays: &[Array<T, IxDyn>]) -> FerrayResult<Array<T, IxDyn>> {
392    if arrays.is_empty() {
393        return Err(FerrayError::invalid_value(
394            "dstack: need at least one array",
395        ));
396    }
397    let mut expanded = Vec::with_capacity(arrays.len());
398    for arr in arrays {
399        let shape = arr.shape();
400        match shape.len() {
401            1 => {
402                let n = shape[0];
403                expanded.push(reshape(arr, &[1, n, 1])?);
404            }
405            2 => {
406                let (m, n) = (shape[0], shape[1]);
407                expanded.push(reshape(arr, &[m, n, 1])?);
408            }
409            _ => {
410                // Already 3-D+, just use as-is
411                let data: Vec<T> = arr.iter().cloned().collect();
412                expanded.push(Array::from_vec(IxDyn::new(shape), data)?);
413            }
414        }
415    }
416    concatenate(&expanded, 2)
417}
418
419/// Stack 1-D arrays as columns into a 2-D array.
420///
421/// Each input becomes one column of the output. For 2-D+ inputs, this is
422/// equivalent to [`hstack`].
423///
424/// Analogous to `numpy.column_stack()`.
425///
426/// # Errors
427/// Returns `FerrayError::InvalidValue` if the input is empty,
428/// or `FerrayError::ShapeMismatch` if 1-D inputs have different lengths.
429pub fn column_stack<T: Element>(arrays: &[Array<T, IxDyn>]) -> FerrayResult<Array<T, IxDyn>> {
430    if arrays.is_empty() {
431        return Err(FerrayError::invalid_value(
432            "column_stack: need at least one array",
433        ));
434    }
435    let first_ndim = arrays[0].ndim();
436    if first_ndim == 1 {
437        // Convert each 1-D array of length N into a (N, 1) column, then hstack.
438        let n = arrays[0].shape()[0];
439        let mut reshaped = Vec::with_capacity(arrays.len());
440        for arr in arrays {
441            if arr.ndim() != 1 {
442                return Err(FerrayError::shape_mismatch(
443                    "column_stack: all inputs must have the same ndim",
444                ));
445            }
446            if arr.shape()[0] != n {
447                return Err(FerrayError::shape_mismatch(format!(
448                    "column_stack: 1-D inputs must have the same length; got {} and {}",
449                    n,
450                    arr.shape()[0],
451                )));
452            }
453            reshaped.push(reshape(arr, &[n, 1])?);
454        }
455        concatenate(&reshaped, 1)
456    } else {
457        // 2-D+: same as hstack
458        hstack(arrays)
459    }
460}
461
462/// Stack arrays in sequence vertically (row-wise). Alias for [`vstack`].
463///
464/// Analogous to `numpy.row_stack()` (deprecated alias for `vstack` in `NumPy` 2.0
465/// but still widely used).
466pub fn row_stack<T: Element>(arrays: &[Array<T, IxDyn>]) -> FerrayResult<Array<T, IxDyn>> {
467    vstack(arrays)
468}
469
470/// Assemble an array from nested blocks.
471///
472/// Simplified version: takes a 2-D grid of arrays (as Vec<Vec<...>>)
473/// and assembles them by stacking rows horizontally, then all rows vertically.
474///
475/// Analogous to `numpy.block()`.
476///
477/// # Errors
478/// Returns errors on shape mismatches.
479pub fn block<T: Element>(blocks: &[Vec<Array<T, IxDyn>>]) -> FerrayResult<Array<T, IxDyn>> {
480    if blocks.is_empty() {
481        return Err(FerrayError::invalid_value("block: empty input"));
482    }
483    let mut rows = Vec::with_capacity(blocks.len());
484    for row in blocks {
485        if row.is_empty() {
486            return Err(FerrayError::invalid_value("block: empty row"));
487        }
488        // Concatenate along axis 1 (columns within each row)
489        let row_arr = if row.len() == 1 {
490            let data: Vec<T> = row[0].iter().cloned().collect();
491            Array::from_vec(IxDyn::new(row[0].shape()), data)?
492        } else {
493            hstack(row)?
494        };
495        rows.push(row_arr);
496    }
497    if rows.len() == 1 {
498        // SAFETY: just checked len() == 1, so pop() always returns Some
499        Ok(rows.pop().unwrap_or_else(|| unreachable!()))
500    } else {
501        vstack(&rows)
502    }
503}
504
505/// Split an array into equal-sized sub-arrays.
506///
507/// `n_sections` must evenly divide the size along `axis`.
508///
509/// Analogous to `numpy.split()`.
510///
511/// # Errors
512/// Returns `FerrayError::InvalidValue` if the axis cannot be evenly split.
513pub fn split<T: Element>(
514    a: &Array<T, IxDyn>,
515    n_sections: usize,
516    axis: usize,
517) -> FerrayResult<Vec<Array<T, IxDyn>>> {
518    let shape = a.shape();
519    if axis >= shape.len() {
520        return Err(FerrayError::axis_out_of_bounds(axis, shape.len()));
521    }
522    let axis_len = shape[axis];
523    if n_sections == 0 {
524        return Err(FerrayError::invalid_value("split: n_sections must be > 0"));
525    }
526    if axis_len % n_sections != 0 {
527        return Err(FerrayError::invalid_value(format!(
528            "array of size {axis_len} along axis {axis} cannot be evenly split into {n_sections} sections",
529        )));
530    }
531    let chunk_size = axis_len / n_sections;
532    let indices: Vec<usize> = (1..n_sections).map(|i| i * chunk_size).collect();
533    array_split(a, &indices, axis)
534}
535
536/// Split an array into sub-arrays at the given indices along `axis`.
537///
538/// Unlike `split()`, this does not require even division.
539///
540/// Analogous to `numpy.array_split()` (with explicit split points).
541///
542/// # Errors
543/// Returns `FerrayError::AxisOutOfBounds` if axis is invalid.
544pub fn array_split<T: Element>(
545    a: &Array<T, IxDyn>,
546    indices: &[usize],
547    axis: usize,
548) -> FerrayResult<Vec<Array<T, IxDyn>>> {
549    let shape = a.shape();
550    let ndim = shape.len();
551    if axis >= ndim {
552        return Err(FerrayError::axis_out_of_bounds(axis, ndim));
553    }
554    let axis_len = shape[axis];
555    let src_data: Vec<T> = a.iter().cloned().collect();
556
557    // Build split points including 0 and axis_len
558    let mut splits = Vec::with_capacity(indices.len() + 2);
559    splits.push(0);
560    for &idx in indices {
561        splits.push(idx.min(axis_len));
562    }
563    splits.push(axis_len);
564
565    // Compute source strides (C-order)
566    let mut src_strides = vec![1usize; ndim];
567    for i in (0..ndim - 1).rev() {
568        src_strides[i] = src_strides[i + 1] * shape[i + 1];
569    }
570
571    let mut result = Vec::with_capacity(splits.len() - 1);
572    for w in splits.windows(2) {
573        let start = w[0];
574        let end = w[1];
575        let chunk_len = end - start;
576
577        let mut sub_shape = shape.to_vec();
578        sub_shape[axis] = chunk_len;
579        let sub_total: usize = sub_shape.iter().product();
580
581        // Compute sub strides
582        let mut sub_strides = vec![1usize; ndim];
583        for i in (0..ndim - 1).rev() {
584            sub_strides[i] = sub_strides[i + 1] * sub_shape[i + 1];
585        }
586
587        let mut sub_data = Vec::with_capacity(sub_total);
588        for flat in 0..sub_total {
589            // Convert to nd-index in sub array
590            let mut rem = flat;
591            let mut src_flat = 0usize;
592            for i in 0..ndim {
593                let idx = rem / sub_strides[i];
594                rem %= sub_strides[i];
595                let src_idx = if i == axis { idx + start } else { idx };
596                src_flat += src_idx * src_strides[i];
597            }
598            sub_data.push(src_data[src_flat].clone());
599        }
600        result.push(Array::from_vec(IxDyn::new(&sub_shape), sub_data)?);
601    }
602
603    Ok(result)
604}
605
606/// Split an array into `n` sub-arrays along `axis`, allowing uneven sections.
607///
608/// Unlike [`split`], this never errors on uneven division: the first
609/// `axis_len % n` sections have one extra element. This matches `NumPy`'s
610/// `numpy.array_split(ary, n, axis)` (integer-section variant).
611///
612/// # Errors
613/// Returns `FerrayError::AxisOutOfBounds` if `axis >= ndim`.
614/// Returns `FerrayError::InvalidValue` if `n == 0`.
615pub fn array_split_n<T: Element>(
616    a: &Array<T, IxDyn>,
617    n: usize,
618    axis: usize,
619) -> FerrayResult<Vec<Array<T, IxDyn>>> {
620    if n == 0 {
621        return Err(FerrayError::invalid_value("array_split_n: n must be > 0"));
622    }
623    let shape = a.shape();
624    if axis >= shape.len() {
625        return Err(FerrayError::axis_out_of_bounds(axis, shape.len()));
626    }
627    let axis_len = shape[axis];
628
629    // Build split indices following NumPy's array_split:
630    // First (axis_len % n) sections get (axis_len / n + 1) elements,
631    // remaining sections get (axis_len / n) elements.
632    let base = axis_len / n;
633    let extra = axis_len % n;
634    let mut indices = Vec::with_capacity(n.saturating_sub(1));
635    let mut cum = 0usize;
636    for i in 0..n - 1 {
637        cum += if i < extra { base + 1 } else { base };
638        indices.push(cum);
639    }
640    array_split(a, &indices, axis)
641}
642
643/// Split array along axis 0 (vertical split). Equivalent to `split(a, n, 0)`.
644///
645/// Analogous to `numpy.vsplit()`.
646pub fn vsplit<T: Element>(
647    a: &Array<T, IxDyn>,
648    n_sections: usize,
649) -> FerrayResult<Vec<Array<T, IxDyn>>> {
650    split(a, n_sections, 0)
651}
652
653/// Split array along axis 1 (horizontal split). Equivalent to `split(a, n, 1)`.
654///
655/// Analogous to `numpy.hsplit()`.
656pub fn hsplit<T: Element>(
657    a: &Array<T, IxDyn>,
658    n_sections: usize,
659) -> FerrayResult<Vec<Array<T, IxDyn>>> {
660    split(a, n_sections, 1)
661}
662
663/// Split array along axis 2 (depth split). Equivalent to `split(a, n, 2)`.
664///
665/// Analogous to `numpy.dsplit()`.
666pub fn dsplit<T: Element>(
667    a: &Array<T, IxDyn>,
668    n_sections: usize,
669) -> FerrayResult<Vec<Array<T, IxDyn>>> {
670    split(a, n_sections, 2)
671}
672
673// ============================================================================
674// REQ-22: Transpose / reorder
675// ============================================================================
676
677/// Permute the axes of an array.
678///
679/// `axes` specifies the new ordering. For a 2-D array, `[1, 0]` transposes.
680/// If `axes` is `None`, reverses the order of all axes.
681///
682/// Analogous to `numpy.transpose()`.
683///
684/// # Errors
685/// Returns `FerrayError::InvalidValue` if `axes` is the wrong length or
686/// contains invalid/duplicate axis indices.
687pub fn transpose<T: Element, D: Dimension>(
688    a: &Array<T, D>,
689    axes: Option<&[usize]>,
690) -> FerrayResult<Array<T, IxDyn>> {
691    let ndim = a.ndim();
692    let perm: Vec<usize> = match axes {
693        Some(ax) => {
694            if ax.len() != ndim {
695                return Err(FerrayError::invalid_value(format!(
696                    "axes must have length {} but got {}",
697                    ndim,
698                    ax.len(),
699                )));
700            }
701            // Validate: each axis appears exactly once
702            let mut seen = vec![false; ndim];
703            for &a in ax {
704                if a >= ndim {
705                    return Err(FerrayError::axis_out_of_bounds(a, ndim));
706                }
707                if seen[a] {
708                    return Err(FerrayError::invalid_value(format!(
709                        "duplicate axis {a} in transpose",
710                    )));
711                }
712                seen[a] = true;
713            }
714            ax.to_vec()
715        }
716        None => (0..ndim).rev().collect(),
717    };
718
719    // Permute axes is a zero-copy stride rearrangement in ndarray; we
720    // then call `as_standard_layout` which returns a borrowed view when
721    // already C-contiguous or materializes a single standard-layout
722    // owned buffer otherwise. The old implementation walked every
723    // output index in a hand-rolled scatter loop (issue #82). Plain
724    // `to_owned()` would preserve the F-contiguous stride pattern from
725    // the permutation, which downstream callers don't want because
726    // `as_slice()` only succeeds on standard layouts.
727    let permuted = a
728        .inner
729        .view()
730        .into_dyn()
731        .permuted_axes(ndarray::IxDyn(&perm));
732    Ok(Array::from_ndarray(
733        permuted.as_standard_layout().into_owned(),
734    ))
735}
736
737/// Swap two axes of an array.
738///
739/// Analogous to `numpy.swapaxes()`.
740///
741/// # Errors
742/// Returns `FerrayError::AxisOutOfBounds` if either axis is out of bounds.
743pub fn swapaxes<T: Element, D: Dimension>(
744    a: &Array<T, D>,
745    axis1: usize,
746    axis2: usize,
747) -> FerrayResult<Array<T, IxDyn>> {
748    let ndim = a.ndim();
749    if axis1 >= ndim {
750        return Err(FerrayError::axis_out_of_bounds(axis1, ndim));
751    }
752    if axis2 >= ndim {
753        return Err(FerrayError::axis_out_of_bounds(axis2, ndim));
754    }
755    let mut perm: Vec<usize> = (0..ndim).collect();
756    perm.swap(axis1, axis2);
757    transpose(a, Some(&perm))
758}
759
760/// Move an axis to a new position.
761///
762/// Analogous to `numpy.moveaxis()`.
763///
764/// # Errors
765/// Returns `FerrayError::AxisOutOfBounds` if either axis is out of bounds.
766pub fn moveaxis<T: Element, D: Dimension>(
767    a: &Array<T, D>,
768    source: usize,
769    destination: usize,
770) -> FerrayResult<Array<T, IxDyn>> {
771    let ndim = a.ndim();
772    if source >= ndim {
773        return Err(FerrayError::axis_out_of_bounds(source, ndim));
774    }
775    if destination >= ndim {
776        return Err(FerrayError::axis_out_of_bounds(destination, ndim));
777    }
778    // Build permutation by removing source and inserting at destination
779    let mut order: Vec<usize> = (0..ndim).filter(|&x| x != source).collect();
780    order.insert(destination, source);
781    transpose(a, Some(&order))
782}
783
784/// Roll an axis to a new position (similar to moveaxis).
785///
786/// Analogous to `numpy.rollaxis()`.
787///
788/// # Errors
789/// Returns `FerrayError::AxisOutOfBounds` if `axis >= ndim` or `start > ndim`.
790pub fn rollaxis<T: Element, D: Dimension>(
791    a: &Array<T, D>,
792    axis: usize,
793    start: usize,
794) -> FerrayResult<Array<T, IxDyn>> {
795    let ndim = a.ndim();
796    if axis >= ndim {
797        return Err(FerrayError::axis_out_of_bounds(axis, ndim));
798    }
799    if start > ndim {
800        return Err(FerrayError::axis_out_of_bounds(start, ndim + 1));
801    }
802    let dst = if start > axis { start - 1 } else { start };
803    if axis == dst {
804        // No-op: return a copy
805        let data: Vec<T> = a.iter().cloned().collect();
806        return Array::from_vec(IxDyn::new(a.shape()), data);
807    }
808    moveaxis(a, axis, dst)
809}
810
811/// Reverse the order of elements along the given axis.
812///
813/// Analogous to `numpy.flip()`.
814///
815/// # Errors
816/// Returns `FerrayError::AxisOutOfBounds` if axis is out of bounds.
817pub fn flip<T: Element, D: Dimension>(
818    a: &Array<T, D>,
819    axis: usize,
820) -> FerrayResult<Array<T, IxDyn>> {
821    let shape = a.shape();
822    let ndim = shape.len();
823    if axis >= ndim {
824        return Err(FerrayError::axis_out_of_bounds(axis, ndim));
825    }
826    let src_data: Vec<T> = a.iter().cloned().collect();
827    let total = src_data.len();
828
829    // Compute strides (C-order)
830    let mut strides = vec![1usize; ndim];
831    for i in (0..ndim.saturating_sub(1)).rev() {
832        strides[i] = strides[i + 1] * shape[i + 1];
833    }
834
835    let mut data = Vec::with_capacity(total);
836    for flat in 0..total {
837        let mut rem = flat;
838        let mut src_flat = 0usize;
839        for i in 0..ndim {
840            let idx = rem / strides[i];
841            rem %= strides[i];
842            let src_idx = if i == axis { shape[i] - 1 - idx } else { idx };
843            src_flat += src_idx * strides[i];
844        }
845        data.push(src_data[src_flat].clone());
846    }
847    Array::from_vec(IxDyn::new(shape), data)
848}
849
850/// Flip array left-right (reverse axis 1).
851///
852/// Analogous to `numpy.fliplr()`.
853///
854/// # Errors
855/// Returns `FerrayError::InvalidValue` if the array has fewer than 2 dimensions.
856pub fn fliplr<T: Element, D: Dimension>(a: &Array<T, D>) -> FerrayResult<Array<T, IxDyn>> {
857    if a.ndim() < 2 {
858        return Err(FerrayError::invalid_value(
859            "fliplr: array must be at least 2-D",
860        ));
861    }
862    flip(a, 1)
863}
864
865/// Flip array up-down (reverse axis 0).
866///
867/// Analogous to `numpy.flipud()`.
868///
869/// # Errors
870/// Returns `FerrayError::InvalidValue` if the array has 0 dimensions.
871pub fn flipud<T: Element, D: Dimension>(a: &Array<T, D>) -> FerrayResult<Array<T, IxDyn>> {
872    if a.ndim() < 1 {
873        return Err(FerrayError::invalid_value(
874            "flipud: array must be at least 1-D",
875        ));
876    }
877    flip(a, 0)
878}
879
880/// Rotate array 90 degrees counterclockwise in the plane defined by axes (0, 1).
881///
882/// `k` specifies the number of 90-degree rotations (can be negative).
883///
884/// Analogous to `numpy.rot90()`.
885///
886/// # Errors
887/// Returns `FerrayError::InvalidValue` if the array has fewer than 2 dimensions.
888pub fn rot90<T: Element, D: Dimension>(a: &Array<T, D>, k: i32) -> FerrayResult<Array<T, IxDyn>> {
889    if a.ndim() < 2 {
890        return Err(FerrayError::invalid_value(
891            "rot90: array must be at least 2-D",
892        ));
893    }
894    // Normalize k to [0, 4)
895    let k = k.rem_euclid(4);
896    let shape = a.shape();
897    let data: Vec<T> = a.iter().cloned().collect();
898
899    // We work with the IxDyn representation
900    let as_dyn = Array::from_vec(IxDyn::new(shape), data)?;
901
902    match k {
903        0 => Ok(as_dyn),
904        1 => {
905            // rot90 once: flip axis 1, then transpose axes 0,1
906            let flipped = flip(&as_dyn, 1)?;
907            swapaxes(&flipped, 0, 1)
908        }
909        2 => {
910            // rot180: flip both axes
911            let f1 = flip(&as_dyn, 0)?;
912            flip(&f1, 1)
913        }
914        3 => {
915            // rot270: transpose, then flip axis 1
916            let transposed = swapaxes(&as_dyn, 0, 1)?;
917            flip(&transposed, 1)
918        }
919        _ => unreachable!(),
920    }
921}
922
923/// Roll elements along an axis. Elements that roll past the end
924/// are re-introduced at the beginning.
925///
926/// If `axis` is `None`, the array is flattened first, then rolled.
927///
928/// Analogous to `numpy.roll()`.
929///
930/// # Errors
931/// Returns `FerrayError::AxisOutOfBounds` if axis is out of bounds.
932pub fn roll<T: Element, D: Dimension>(
933    a: &Array<T, D>,
934    shift: isize,
935    axis: Option<usize>,
936) -> FerrayResult<Array<T, IxDyn>> {
937    match axis {
938        None => {
939            // Flatten, roll, reshape back
940            let data: Vec<T> = a.iter().cloned().collect();
941            let n = data.len();
942            if n == 0 {
943                return Array::from_vec(IxDyn::new(a.shape()), data);
944            }
945            let shift = ((shift % n as isize) + n as isize) as usize % n;
946            let mut rolled = Vec::with_capacity(n);
947            for i in 0..n {
948                rolled.push(data[(n + i - shift) % n].clone());
949            }
950            Array::from_vec(IxDyn::new(a.shape()), rolled)
951        }
952        Some(ax) => {
953            let shape = a.shape();
954            let ndim = shape.len();
955            if ax >= ndim {
956                return Err(FerrayError::axis_out_of_bounds(ax, ndim));
957            }
958            let axis_len = shape[ax];
959            if axis_len == 0 {
960                let data: Vec<T> = a.iter().cloned().collect();
961                return Array::from_vec(IxDyn::new(shape), data);
962            }
963            let shift = ((shift % axis_len as isize) + axis_len as isize) as usize % axis_len;
964            let src_data: Vec<T> = a.iter().cloned().collect();
965            let total = src_data.len();
966
967            // Compute strides (C-order)
968            let mut strides = vec![1usize; ndim];
969            for i in (0..ndim.saturating_sub(1)).rev() {
970                strides[i] = strides[i + 1] * shape[i + 1];
971            }
972
973            let mut data = Vec::with_capacity(total);
974            for flat in 0..total {
975                let mut rem = flat;
976                let mut src_flat = 0usize;
977                #[allow(clippy::needless_range_loop)]
978                for i in 0..ndim {
979                    let idx = rem / strides[i];
980                    rem %= strides[i];
981                    let src_idx = if i == ax {
982                        (axis_len + idx - shift) % axis_len
983                    } else {
984                        idx
985                    };
986                    src_flat += src_idx * strides[i];
987                }
988                data.push(src_data[src_flat].clone());
989            }
990            Array::from_vec(IxDyn::new(shape), data)
991        }
992    }
993}
994
995// ============================================================================
996// REQ: r_ / c_ — quick row/column builders (functional analogues of NumPy's
997//                np.r_ / np.c_ index_exp slicing-tuple machinery)
998// ============================================================================
999
1000/// Concatenate a sequence of 1-D arrays into a single 1-D array.
1001///
1002/// This is the function-form of `numpy.r_[a, b, c, ...]` for the simple
1003/// case of building up a 1-D vector. NumPy's `r_` also accepts slice
1004/// objects (`r_[1:5, 8:12]`) which Rust syntax cannot replicate cleanly;
1005/// for that, build the components with [`arange`](crate::creation::arange) /
1006/// [`linspace`](crate::creation::linspace) first, then call `r_`.
1007///
1008/// # Errors
1009/// Returns `FerrayError::InvalidValue` if `arrays` is empty.
1010pub fn r_<T: Element>(arrays: &[Array<T, Ix1>]) -> FerrayResult<Array<T, Ix1>> {
1011    if arrays.is_empty() {
1012        return Err(FerrayError::invalid_value("r_: need at least one array"));
1013    }
1014    let total: usize = arrays.iter().map(|a| a.shape()[0]).sum();
1015    let mut data = Vec::with_capacity(total);
1016    for a in arrays {
1017        data.extend(a.iter().cloned());
1018    }
1019    Array::from_vec(Ix1::new([total]), data)
1020}
1021
1022/// Stack a sequence of 1-D arrays as columns of a 2-D array.
1023///
1024/// All inputs must have the same length `n`. The result has shape
1025/// `(n, k)` where `k = arrays.len()`.
1026///
1027/// Function-form of `numpy.c_[a, b, c]` when each input is 1-D.
1028///
1029/// # Errors
1030/// Returns `FerrayError::InvalidValue` if `arrays` is empty.
1031/// Returns `FerrayError::ShapeMismatch` if the inputs differ in length.
1032pub fn c_<T: Element>(arrays: &[Array<T, Ix1>]) -> FerrayResult<Array<T, IxDyn>> {
1033    if arrays.is_empty() {
1034        return Err(FerrayError::invalid_value("c_: need at least one array"));
1035    }
1036    let n = arrays[0].shape()[0];
1037    for a in &arrays[1..] {
1038        if a.shape()[0] != n {
1039            return Err(FerrayError::shape_mismatch(format!(
1040                "c_: all 1-D inputs must have the same length; got {} and {}",
1041                n,
1042                a.shape()[0],
1043            )));
1044        }
1045    }
1046    let k = arrays.len();
1047    // Output is row-major (n, k); element (i, j) comes from arrays[j][i].
1048    let cols: Vec<Vec<T>> = arrays.iter().map(|a| a.iter().cloned().collect()).collect();
1049    let mut data = Vec::with_capacity(n * k);
1050    for i in 0..n {
1051        for col in &cols {
1052            data.push(col[i].clone());
1053        }
1054    }
1055    Array::from_vec(IxDyn::new(&[n, k]), data)
1056}
1057
1058// ============================================================================
1059// Tests
1060// ============================================================================
1061
1062#[cfg(test)]
1063mod tests {
1064    use super::*;
1065
1066    fn dyn_arr(shape: &[usize], data: Vec<f64>) -> Array<f64, IxDyn> {
1067        Array::from_vec(IxDyn::new(shape), data).unwrap()
1068    }
1069
1070    // -- REQ-20 --
1071
1072    #[test]
1073    fn test_reshape() {
1074        let a = dyn_arr(&[2, 3], vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
1075        let b = reshape(&a, &[3, 2]).unwrap();
1076        assert_eq!(b.shape(), &[3, 2]);
1077        let data: Vec<f64> = b.iter().copied().collect();
1078        assert_eq!(data, vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
1079    }
1080
1081    #[test]
1082    fn test_reshape_size_mismatch() {
1083        let a = dyn_arr(&[2, 3], vec![1.0; 6]);
1084        assert!(reshape(&a, &[2, 4]).is_err());
1085    }
1086
1087    #[test]
1088    fn test_ravel() {
1089        let a = dyn_arr(&[2, 3], vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
1090        let b = ravel(&a).unwrap();
1091        assert_eq!(b.shape(), &[6]);
1092        assert_eq!(b.as_slice().unwrap(), &[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
1093    }
1094
1095    #[test]
1096    fn test_flatten() {
1097        let a = dyn_arr(&[2, 3], vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
1098        let b = flatten(&a).unwrap();
1099        assert_eq!(b.shape(), &[6]);
1100    }
1101
1102    #[test]
1103    fn test_squeeze() {
1104        let a = dyn_arr(&[1, 3, 1], vec![1.0, 2.0, 3.0]);
1105        let b = squeeze(&a, None).unwrap();
1106        assert_eq!(b.shape(), &[3]);
1107    }
1108
1109    #[test]
1110    fn test_squeeze_specific_axis() {
1111        let a = dyn_arr(&[1, 3, 1], vec![1.0, 2.0, 3.0]);
1112        let b = squeeze(&a, Some(0)).unwrap();
1113        assert_eq!(b.shape(), &[3, 1]);
1114    }
1115
1116    #[test]
1117    fn test_squeeze_not_size_1() {
1118        let a = dyn_arr(&[2, 3], vec![1.0; 6]);
1119        assert!(squeeze(&a, Some(0)).is_err());
1120    }
1121
1122    #[test]
1123    fn test_expand_dims() {
1124        let a = dyn_arr(&[3], vec![1.0, 2.0, 3.0]);
1125        let b = expand_dims(&a, 0).unwrap();
1126        assert_eq!(b.shape(), &[1, 3]);
1127        let c = expand_dims(&a, 1).unwrap();
1128        assert_eq!(c.shape(), &[3, 1]);
1129    }
1130
1131    #[test]
1132    fn test_expand_dims_oob() {
1133        let a = dyn_arr(&[3], vec![1.0, 2.0, 3.0]);
1134        assert!(expand_dims(&a, 3).is_err());
1135    }
1136
1137    #[test]
1138    fn test_broadcast_to() {
1139        let a = dyn_arr(&[1, 3], vec![1.0, 2.0, 3.0]);
1140        let b = broadcast_to(&a, &[3, 3]).unwrap();
1141        assert_eq!(b.shape(), &[3, 3]);
1142        let data: Vec<f64> = b.iter().copied().collect();
1143        assert_eq!(data, vec![1.0, 2.0, 3.0, 1.0, 2.0, 3.0, 1.0, 2.0, 3.0]);
1144    }
1145
1146    #[test]
1147    fn test_broadcast_to_1d_to_2d() {
1148        let a = dyn_arr(&[3], vec![1.0, 2.0, 3.0]);
1149        let b = broadcast_to(&a, &[2, 3]).unwrap();
1150        assert_eq!(b.shape(), &[2, 3]);
1151    }
1152
1153    #[test]
1154    fn test_broadcast_to_incompatible() {
1155        let a = dyn_arr(&[4], vec![1.0, 2.0, 3.0, 4.0]);
1156        assert!(broadcast_to(&a, &[3]).is_err());
1157    }
1158
1159    #[test]
1160    fn test_broadcast_to_from_non_contiguous_source() {
1161        // Issue #133: broadcast_to's source may itself be non-contiguous
1162        // (e.g., transposed). Since broadcast_to now delegates to
1163        // ndarray's `broadcast` which handles any stride pattern, the
1164        // result should materialize correctly.
1165        let a = dyn_arr(&[2, 3], vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
1166        // Transpose gives us a 3x2 logical view — then broadcast to (2, 3, 2).
1167        let t = transpose(&a, None).unwrap();
1168        let b = broadcast_to(&t, &[2, 3, 2]).unwrap();
1169        assert_eq!(b.shape(), &[2, 3, 2]);
1170        // Both outer slices should be identical.
1171        let data: Vec<f64> = b.iter().copied().collect();
1172        assert_eq!(&data[..6], &data[6..12]);
1173    }
1174
1175    // -- REQ-21 --
1176
1177    #[test]
1178    fn test_concatenate() {
1179        let a = dyn_arr(&[2, 3], vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
1180        let b = dyn_arr(&[2, 3], vec![7.0, 8.0, 9.0, 10.0, 11.0, 12.0]);
1181        let c = concatenate(&[a, b], 0).unwrap();
1182        assert_eq!(c.shape(), &[4, 3]);
1183    }
1184
1185    #[test]
1186    fn test_concatenate_axis1() {
1187        let a = dyn_arr(&[2, 2], vec![1.0, 2.0, 3.0, 4.0]);
1188        let b = dyn_arr(&[2, 3], vec![5.0, 6.0, 7.0, 8.0, 9.0, 10.0]);
1189        let c = concatenate(&[a, b], 1).unwrap();
1190        assert_eq!(c.shape(), &[2, 5]);
1191    }
1192
1193    #[test]
1194    fn test_concatenate_shape_mismatch() {
1195        let a = dyn_arr(&[2, 3], vec![1.0; 6]);
1196        let b = dyn_arr(&[3, 3], vec![1.0; 9]);
1197        // Axis 0: different sizes on axis 1? No — axis 1 is same (3).
1198        // But axis 0 concat: shapes are [2,3] and [3,3], axis 0 can differ.
1199        // Non-concat axis (1) matches.
1200        let c = concatenate(&[a, b], 0).unwrap();
1201        assert_eq!(c.shape(), &[5, 3]);
1202    }
1203
1204    #[test]
1205    fn test_concatenate_empty() {
1206        let v: Vec<Array<f64, IxDyn>> = vec![];
1207        assert!(concatenate(&v, 0).is_err());
1208    }
1209
1210    #[test]
1211    fn test_stack() {
1212        let a = dyn_arr(&[3], vec![1.0, 2.0, 3.0]);
1213        let b = dyn_arr(&[3], vec![4.0, 5.0, 6.0]);
1214        let c = stack(&[a, b], 0).unwrap();
1215        assert_eq!(c.shape(), &[2, 3]);
1216        let data: Vec<f64> = c.iter().copied().collect();
1217        assert_eq!(data, vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
1218    }
1219
1220    #[test]
1221    fn test_stack_axis1() {
1222        let a = dyn_arr(&[3], vec![1.0, 2.0, 3.0]);
1223        let b = dyn_arr(&[3], vec![4.0, 5.0, 6.0]);
1224        let c = stack(&[a, b], 1).unwrap();
1225        assert_eq!(c.shape(), &[3, 2]);
1226        let data: Vec<f64> = c.iter().copied().collect();
1227        assert_eq!(data, vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0]);
1228    }
1229
1230    #[test]
1231    fn test_vstack() {
1232        let a = dyn_arr(&[3], vec![1.0, 2.0, 3.0]);
1233        let b = dyn_arr(&[3], vec![4.0, 5.0, 6.0]);
1234        let c = vstack(&[a, b]).unwrap();
1235        assert_eq!(c.shape(), &[2, 3]);
1236    }
1237
1238    #[test]
1239    fn test_hstack() {
1240        let a = dyn_arr(&[3], vec![1.0, 2.0, 3.0]);
1241        let b = dyn_arr(&[3], vec![4.0, 5.0, 6.0]);
1242        let c = hstack(&[a, b]).unwrap();
1243        assert_eq!(c.shape(), &[6]);
1244    }
1245
1246    #[test]
1247    fn test_hstack_2d() {
1248        let a = dyn_arr(&[2, 2], vec![1.0, 2.0, 3.0, 4.0]);
1249        let b = dyn_arr(&[2, 3], vec![5.0, 6.0, 7.0, 8.0, 9.0, 10.0]);
1250        let c = hstack(&[a, b]).unwrap();
1251        assert_eq!(c.shape(), &[2, 5]);
1252    }
1253
1254    #[test]
1255    fn test_dstack() {
1256        let a = dyn_arr(&[2, 2], vec![1.0, 2.0, 3.0, 4.0]);
1257        let b = dyn_arr(&[2, 2], vec![5.0, 6.0, 7.0, 8.0]);
1258        let c = dstack(&[a, b]).unwrap();
1259        assert_eq!(c.shape(), &[2, 2, 2]);
1260    }
1261
1262    #[test]
1263    fn test_block() {
1264        let a = dyn_arr(&[2, 2], vec![1.0, 2.0, 3.0, 4.0]);
1265        let b = dyn_arr(&[2, 2], vec![5.0, 6.0, 7.0, 8.0]);
1266        let c = dyn_arr(&[2, 2], vec![9.0, 10.0, 11.0, 12.0]);
1267        let d = dyn_arr(&[2, 2], vec![13.0, 14.0, 15.0, 16.0]);
1268        let result = block(&[vec![a, b], vec![c, d]]).unwrap();
1269        assert_eq!(result.shape(), &[4, 4]);
1270    }
1271
1272    #[test]
1273    fn test_split() {
1274        let a = dyn_arr(&[6], vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
1275        let parts = split(&a, 3, 0).unwrap();
1276        assert_eq!(parts.len(), 3);
1277        assert_eq!(parts[0].shape(), &[2]);
1278        assert_eq!(parts[1].shape(), &[2]);
1279        assert_eq!(parts[2].shape(), &[2]);
1280    }
1281
1282    #[test]
1283    fn test_split_uneven() {
1284        let a = dyn_arr(&[5], vec![1.0, 2.0, 3.0, 4.0, 5.0]);
1285        assert!(split(&a, 3, 0).is_err()); // 5 not divisible by 3
1286    }
1287
1288    #[test]
1289    fn test_array_split() {
1290        let a = dyn_arr(&[5], vec![1.0, 2.0, 3.0, 4.0, 5.0]);
1291        let parts = array_split(&a, &[2, 4], 0).unwrap();
1292        assert_eq!(parts.len(), 3);
1293        assert_eq!(parts[0].shape(), &[2]); // [1,2]
1294        assert_eq!(parts[1].shape(), &[2]); // [3,4]
1295        assert_eq!(parts[2].shape(), &[1]); // [5]
1296    }
1297
1298    #[test]
1299    fn test_vsplit() {
1300        let a = dyn_arr(&[4, 2], vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]);
1301        let parts = vsplit(&a, 2).unwrap();
1302        assert_eq!(parts.len(), 2);
1303        assert_eq!(parts[0].shape(), &[2, 2]);
1304    }
1305
1306    #[test]
1307    fn test_hsplit() {
1308        let a = dyn_arr(&[2, 4], vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]);
1309        let parts = hsplit(&a, 2).unwrap();
1310        assert_eq!(parts.len(), 2);
1311        assert_eq!(parts[0].shape(), &[2, 2]);
1312    }
1313
1314    // -- REQ-22 --
1315
1316    #[test]
1317    fn test_transpose_2d() {
1318        let a = dyn_arr(&[2, 3], vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
1319        let b = transpose(&a, None).unwrap();
1320        assert_eq!(b.shape(), &[3, 2]);
1321        let data: Vec<f64> = b.iter().copied().collect();
1322        assert_eq!(data, vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0]);
1323    }
1324
1325    #[test]
1326    fn test_transpose_explicit() {
1327        let a = dyn_arr(&[2, 3], vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
1328        let b = transpose(&a, Some(&[1, 0])).unwrap();
1329        assert_eq!(b.shape(), &[3, 2]);
1330    }
1331
1332    #[test]
1333    fn test_transpose_bad_axes() {
1334        let a = dyn_arr(&[2, 3], vec![1.0; 6]);
1335        assert!(transpose(&a, Some(&[0])).is_err()); // wrong length
1336    }
1337
1338    #[test]
1339    fn test_swapaxes() {
1340        let a = dyn_arr(&[2, 3, 4], vec![0.0; 24]);
1341        let b = swapaxes(&a, 0, 2).unwrap();
1342        assert_eq!(b.shape(), &[4, 3, 2]);
1343    }
1344
1345    #[test]
1346    fn test_moveaxis() {
1347        let a = dyn_arr(&[2, 3, 4], vec![0.0; 24]);
1348        let b = moveaxis(&a, 0, 2).unwrap();
1349        assert_eq!(b.shape(), &[3, 4, 2]);
1350    }
1351
1352    #[test]
1353    fn test_rollaxis() {
1354        let a = dyn_arr(&[2, 3, 4], vec![0.0; 24]);
1355        let b = rollaxis(&a, 2, 0).unwrap();
1356        assert_eq!(b.shape(), &[4, 2, 3]);
1357    }
1358
1359    #[test]
1360    fn test_flip() {
1361        let a = dyn_arr(&[3], vec![1.0, 2.0, 3.0]);
1362        let b = flip(&a, 0).unwrap();
1363        let data: Vec<f64> = b.iter().copied().collect();
1364        assert_eq!(data, vec![3.0, 2.0, 1.0]);
1365    }
1366
1367    #[test]
1368    fn test_flip_2d() {
1369        let a = dyn_arr(&[2, 3], vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
1370        let b = flip(&a, 0).unwrap();
1371        let data: Vec<f64> = b.iter().copied().collect();
1372        assert_eq!(data, vec![4.0, 5.0, 6.0, 1.0, 2.0, 3.0]);
1373
1374        let c = flip(&a, 1).unwrap();
1375        let data2: Vec<f64> = c.iter().copied().collect();
1376        assert_eq!(data2, vec![3.0, 2.0, 1.0, 6.0, 5.0, 4.0]);
1377    }
1378
1379    #[test]
1380    fn test_fliplr() {
1381        let a = dyn_arr(&[2, 3], vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
1382        let b = fliplr(&a).unwrap();
1383        let data: Vec<f64> = b.iter().copied().collect();
1384        assert_eq!(data, vec![3.0, 2.0, 1.0, 6.0, 5.0, 4.0]);
1385    }
1386
1387    #[test]
1388    fn test_flipud() {
1389        let a = dyn_arr(&[2, 3], vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
1390        let b = flipud(&a).unwrap();
1391        let data: Vec<f64> = b.iter().copied().collect();
1392        assert_eq!(data, vec![4.0, 5.0, 6.0, 1.0, 2.0, 3.0]);
1393    }
1394
1395    #[test]
1396    fn test_fliplr_1d_err() {
1397        let a = dyn_arr(&[3], vec![1.0, 2.0, 3.0]);
1398        assert!(fliplr(&a).is_err());
1399    }
1400
1401    #[test]
1402    fn test_rot90_once() {
1403        // [[1, 2], [3, 4]] -> [[2, 4], [1, 3]]
1404        let a = dyn_arr(&[2, 2], vec![1.0, 2.0, 3.0, 4.0]);
1405        let b = rot90(&a, 1).unwrap();
1406        assert_eq!(b.shape(), &[2, 2]);
1407        let data: Vec<f64> = b.iter().copied().collect();
1408        assert_eq!(data, vec![2.0, 4.0, 1.0, 3.0]);
1409    }
1410
1411    #[test]
1412    fn test_rot90_twice() {
1413        let a = dyn_arr(&[2, 2], vec![1.0, 2.0, 3.0, 4.0]);
1414        let b = rot90(&a, 2).unwrap();
1415        let data: Vec<f64> = b.iter().copied().collect();
1416        assert_eq!(data, vec![4.0, 3.0, 2.0, 1.0]);
1417    }
1418
1419    #[test]
1420    fn test_rot90_four_is_identity() {
1421        let a = dyn_arr(&[2, 3], vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
1422        let b = rot90(&a, 4).unwrap();
1423        let data_a: Vec<f64> = a.iter().copied().collect();
1424        let data_b: Vec<f64> = b.iter().copied().collect();
1425        assert_eq!(data_a, data_b);
1426        assert_eq!(a.shape(), b.shape());
1427    }
1428
1429    #[test]
1430    fn test_roll_flat() {
1431        let a = dyn_arr(&[5], vec![1.0, 2.0, 3.0, 4.0, 5.0]);
1432        let b = roll(&a, 2, None).unwrap();
1433        let data: Vec<f64> = b.iter().copied().collect();
1434        assert_eq!(data, vec![4.0, 5.0, 1.0, 2.0, 3.0]);
1435    }
1436
1437    #[test]
1438    fn test_roll_negative() {
1439        let a = dyn_arr(&[5], vec![1.0, 2.0, 3.0, 4.0, 5.0]);
1440        let b = roll(&a, -2, None).unwrap();
1441        let data: Vec<f64> = b.iter().copied().collect();
1442        assert_eq!(data, vec![3.0, 4.0, 5.0, 1.0, 2.0]);
1443    }
1444
1445    #[test]
1446    fn test_roll_axis() {
1447        let a = dyn_arr(&[2, 3], vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
1448        let b = roll(&a, 1, Some(1)).unwrap();
1449        let data: Vec<f64> = b.iter().copied().collect();
1450        assert_eq!(data, vec![3.0, 1.0, 2.0, 6.0, 4.0, 5.0]);
1451    }
1452
1453    // -----------------------------------------------------------------------
1454    // column_stack / row_stack / array_split_n / to_dyn (issue #362)
1455    // -----------------------------------------------------------------------
1456
1457    #[test]
1458    fn test_column_stack_1d() {
1459        // 3 1-D arrays of length 4 -> (4, 3)
1460        let a = dyn_arr(&[4], vec![1.0, 2.0, 3.0, 4.0]);
1461        let b = dyn_arr(&[4], vec![10.0, 20.0, 30.0, 40.0]);
1462        let c = dyn_arr(&[4], vec![100.0, 200.0, 300.0, 400.0]);
1463        let result = column_stack(&[a, b, c]).unwrap();
1464        assert_eq!(result.shape(), &[4, 3]);
1465        assert_eq!(
1466            result.iter().copied().collect::<Vec<_>>(),
1467            vec![
1468                1.0, 10.0, 100.0, // row 0
1469                2.0, 20.0, 200.0, // row 1
1470                3.0, 30.0, 300.0, // row 2
1471                4.0, 40.0, 400.0, // row 3
1472            ]
1473        );
1474    }
1475
1476    #[test]
1477    fn test_column_stack_2d_same_as_hstack() {
1478        let a = dyn_arr(&[2, 2], vec![1.0, 2.0, 3.0, 4.0]);
1479        let b = dyn_arr(&[2, 2], vec![5.0, 6.0, 7.0, 8.0]);
1480        let result = column_stack(&[a, b]).unwrap();
1481        assert_eq!(result.shape(), &[2, 4]);
1482        assert_eq!(
1483            result.iter().copied().collect::<Vec<_>>(),
1484            vec![1.0, 2.0, 5.0, 6.0, 3.0, 4.0, 7.0, 8.0]
1485        );
1486    }
1487
1488    #[test]
1489    fn test_column_stack_length_mismatch() {
1490        let a = dyn_arr(&[3], vec![1.0, 2.0, 3.0]);
1491        let b = dyn_arr(&[4], vec![1.0, 2.0, 3.0, 4.0]);
1492        assert!(column_stack(&[a, b]).is_err());
1493    }
1494
1495    #[test]
1496    fn test_row_stack_is_vstack() {
1497        let a = dyn_arr(&[3], vec![1.0, 2.0, 3.0]);
1498        let b = dyn_arr(&[3], vec![4.0, 5.0, 6.0]);
1499        let row = row_stack(&[a.clone(), b.clone()]).unwrap();
1500        let v = vstack(&[a, b]).unwrap();
1501        assert_eq!(row.shape(), v.shape());
1502        assert_eq!(
1503            row.iter().copied().collect::<Vec<_>>(),
1504            v.iter().copied().collect::<Vec<_>>()
1505        );
1506    }
1507
1508    #[test]
1509    fn test_array_split_n_uneven() {
1510        // 7 elements split into 3 sections -> [3, 2, 2]
1511        let a = dyn_arr(&[7], vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]);
1512        let parts = array_split_n(&a, 3, 0).unwrap();
1513        assert_eq!(parts.len(), 3);
1514        assert_eq!(
1515            parts[0].iter().copied().collect::<Vec<_>>(),
1516            vec![1.0, 2.0, 3.0]
1517        );
1518        assert_eq!(parts[1].iter().copied().collect::<Vec<_>>(), vec![4.0, 5.0]);
1519        assert_eq!(parts[2].iter().copied().collect::<Vec<_>>(), vec![6.0, 7.0]);
1520    }
1521
1522    #[test]
1523    fn test_array_split_n_even() {
1524        let a = dyn_arr(&[6], vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
1525        let parts = array_split_n(&a, 3, 0).unwrap();
1526        assert_eq!(parts.len(), 3);
1527        for (i, expected) in [vec![1.0, 2.0], vec![3.0, 4.0], vec![5.0, 6.0]]
1528            .iter()
1529            .enumerate()
1530        {
1531            assert_eq!(&parts[i].iter().copied().collect::<Vec<_>>(), expected);
1532        }
1533    }
1534
1535    #[test]
1536    fn test_array_split_n_more_sections_than_elements() {
1537        // NumPy's behavior: 3 elements split into 5 sections gives 5 parts,
1538        // first 3 have 1 element each, last 2 are empty.
1539        let a = dyn_arr(&[3], vec![1.0, 2.0, 3.0]);
1540        let parts = array_split_n(&a, 5, 0).unwrap();
1541        assert_eq!(parts.len(), 5);
1542        assert_eq!(parts[0].iter().copied().collect::<Vec<_>>(), vec![1.0]);
1543        assert_eq!(parts[1].iter().copied().collect::<Vec<_>>(), vec![2.0]);
1544        assert_eq!(parts[2].iter().copied().collect::<Vec<_>>(), vec![3.0]);
1545        assert_eq!(
1546            parts[3].iter().copied().collect::<Vec<_>>(),
1547            Vec::<f64>::new()
1548        );
1549        assert_eq!(
1550            parts[4].iter().copied().collect::<Vec<_>>(),
1551            Vec::<f64>::new()
1552        );
1553    }
1554
1555    #[test]
1556    fn test_to_dyn_from_typed() {
1557        use crate::Array;
1558        use crate::dimension::Ix2;
1559        let typed =
1560            Array::<f64, Ix2>::from_vec(Ix2::new([2, 3]), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
1561                .unwrap();
1562        let dy = typed.to_dyn();
1563        assert_eq!(dy.shape(), &[2, 3]);
1564        assert_eq!(
1565            dy.iter().copied().collect::<Vec<_>>(),
1566            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
1567        );
1568    }
1569
1570    #[test]
1571    fn test_concatenate_typed_via_to_dyn() {
1572        // Demonstrates the typical end-user flow: have Array<T, Ix2>, want to
1573        // concatenate, route through to_dyn().
1574        use crate::Array;
1575        use crate::dimension::Ix2;
1576        let a = Array::<f64, Ix2>::from_vec(Ix2::new([2, 2]), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1577        let b = Array::<f64, Ix2>::from_vec(Ix2::new([2, 2]), vec![5.0, 6.0, 7.0, 8.0]).unwrap();
1578        let result = concatenate(&[a.to_dyn(), b.to_dyn()], 0).unwrap();
1579        assert_eq!(result.shape(), &[4, 2]);
1580        assert_eq!(
1581            result.iter().copied().collect::<Vec<_>>(),
1582            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]
1583        );
1584    }
1585
1586    #[test]
1587    fn test_r_concatenates_1d() {
1588        use crate::Array;
1589        let a = Array::<i32, Ix1>::from_vec(Ix1::new([3]), vec![1, 2, 3]).unwrap();
1590        let b = Array::<i32, Ix1>::from_vec(Ix1::new([2]), vec![4, 5]).unwrap();
1591        let c = Array::<i32, Ix1>::from_vec(Ix1::new([1]), vec![6]).unwrap();
1592        let r = r_(&[a, b, c]).unwrap();
1593        assert_eq!(
1594            r.iter().copied().collect::<Vec<_>>(),
1595            vec![1, 2, 3, 4, 5, 6]
1596        );
1597    }
1598
1599    #[test]
1600    fn test_r_empty_input_errs() {
1601        let r = r_::<f64>(&[]);
1602        assert!(r.is_err());
1603    }
1604
1605    #[test]
1606    fn test_c_columns_to_2d() {
1607        use crate::Array;
1608        let a = Array::<i32, Ix1>::from_vec(Ix1::new([3]), vec![1, 2, 3]).unwrap();
1609        let b = Array::<i32, Ix1>::from_vec(Ix1::new([3]), vec![10, 20, 30]).unwrap();
1610        let r = c_(&[a, b]).unwrap();
1611        assert_eq!(r.shape(), &[3, 2]);
1612        // Row-major: (0,0)=1 (0,1)=10 (1,0)=2 (1,1)=20 (2,0)=3 (2,1)=30
1613        assert_eq!(
1614            r.iter().copied().collect::<Vec<_>>(),
1615            vec![1, 10, 2, 20, 3, 30],
1616        );
1617    }
1618
1619    #[test]
1620    fn test_c_length_mismatch_errs() {
1621        use crate::Array;
1622        let a = Array::<i32, Ix1>::from_vec(Ix1::new([3]), vec![1, 2, 3]).unwrap();
1623        let b = Array::<i32, Ix1>::from_vec(Ix1::new([2]), vec![10, 20]).unwrap();
1624        assert!(c_(&[a, b]).is_err());
1625    }
1626}