From 10b1294aa0817d531559be8305b2ee16469b44c5 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 11 Oct 2026 20:11:02 +0200 Subject: [PATCH] feat: Repr prints exactly what NumPy 2.5.3's repr prints Repr wrote each element in its shortest form and right-aligned them, so its digits and padding differed from NumPy's: array([1.5, 2., 3.]) where NumPy prints array([1.5, 2. , 3. ]), 1e+20 where it prints 1.e+20, " True" missing its space when no False is present, and no scientific notation for arange(2000.). display.go is now a port of numpy/_core/arrayprint.py at v2.5.3 (FloatingFormat in maxprec mode, precision 8; ComplexFloatingFormat; IntegerFormat; BoolFormat; _formatArray and _extendLine; the repr's shape=/dtype= extras and their wrapping), with dragon4.c's digits: the shortest that read back, cut to 8 and rounded half to even on the exact value, which is how strconv rounds. The scientific cut-off depends on the dtype as in 2.5.3: 1e8 for float64, 1e6 for float32. Checked against NumPy's own expected strings (test_arrayprint.py, v2.5.3): 26 repr cases, the 72 complex special values of TestComplexArray.test_str, and gh-28679's float32/complex64 cut-off elements. Breaking the float32 cut-off or the bool padding fails them. Three old expectations encoded the previous, wrong output; corrected. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +- display.go | 446 ++++++++++++++++++++++++++++++------------ display_numpy_test.go | 165 ++++++++++++++++ marshal_test.go | 12 +- 4 files changed, 502 insertions(+), 130 deletions(-) create mode 100644 display_numpy_test.go diff --git a/README.md b/README.md index 2398fb8..3e524e5 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,13 @@ `random.Seed(0)` / `random.Rand(…)`. Bit-exact against NumPy 2.5.3 on x86-64; see [docs/random.md](docs/random.md) for the few ULP-level exceptions (C-library `log`/`exp` rounding) and NumPy's own aarch64 drift. -- **Notebooks and storage** — `Repr` prints like NumPy (summarised above - 1000 elements), `Display` gives a notebook a text and an HTML table, and +- **Notebooks and storage** — `Repr` prints what NumPy 2.5.3's `repr` prints + with its default options, character for character: the same digits (8 at + most, the shortest that read back), padding, scientific switch (at 1e8, 1e6 + for float32), line breaks at 75 columns, summary above 1000 elements and + `shape=`/`dtype=` suffixes. It is a port of `arrayprint.py`, checked against + the expected strings of NumPy's own `test_arrayprint.py`. `Display` gives a + notebook a text and an HTML table, and `MarshalBinary`/`UnmarshalBinary` encode an array the same way on every architecture (so gob and the go-notebook kernel keep arrays). - **Memory reuse** — `Workspace`, an arena for loops: bind the inputs with diff --git a/display.go b/display.go index 7b8054c..e776ff6 100644 --- a/display.go +++ b/display.go @@ -7,48 +7,64 @@ import ( "strings" ) -// Printing, laid out the way NumPy prints: nested brackets, every element -// right-aligned to one width, lines broken at 75 columns, and, above 1000 -// elements, only the first and last three items of each axis with "..." -// between them. Elements are written by the package's own formatting (the -// shortest form that reads back exactly), so the digits may differ from -// NumPy's, which pads every element to a common precision. +// Printing, as NumPy 2.5.3 prints with its default options: a port of +// numpy/_core/arrayprint.py (_array_repr_implementation, array2string, +// _formatArray, _extendLine, FloatingFormat, ComplexFloatingFormat, +// IntegerFormat, BoolFormat) and of the parts of dragon4.c those use +// (positional and scientific output, unique digits, a precision cut-off +// rounded half to even on the exact binary value, as strconv rounds). +// +// The defaults are NumPy's: precision 8 in "maxprec" mode, 75 columns, +// threshold 1000 elements, 3 edge items, sign "-", no small-number +// suppression. The format of each element is decided from the elements that +// are printed, as NumPy decides it from _leading_trailing(data). const ( printThreshold = 1000 // above this many elements, summarise printEdge = 3 // items kept at each end of a summarised axis printWidth = 75 // line width + printPrecision = 8 // fraction digits at most, in "maxprec" mode ) -// Repr returns the array as NumPy's repr shows it: array([...]), with the -// dtype when it is not the default for its kind, and the shape when the -// elements are summarised. +// Repr returns the array as NumPy's repr shows it: array([...]), followed by +// the shape when the elements are summarised or the array is empty, and the +// dtype when the values do not imply it (float64, int64, bool and complex128 +// do) or the array is empty. func (a *Array) Repr() string { - const open = "array(" - body := a.format(len(open)) - var b strings.Builder - b.WriteString(open) - b.WriteString(body) - if a.Size() > printThreshold { - b.WriteString(", shape=(") - for i, d := range a.shape { - if i > 0 { - b.WriteString(", ") - } - b.WriteString(strconv.Itoa(d)) - } - if len(a.shape) == 1 { - b.WriteByte(',') - } - b.WriteByte(')') + const prefix = "array(" + var lst string + if a.Size() == 0 { + lst = "[]" + } else { + lst = a.formatArray(a.elementFormatter(), printWidth-len(")"), strings.Repeat(" ", len(prefix)+1)) + } + var extras []string + if (a.Size() == 0 && !(len(a.shape) == 1 && a.shape[0] == 0)) || a.Size() > printThreshold { + extras = append(extras, "shape="+shapeString(a.shape)) + } + if !dtypeImplied(a.dtype) || a.Size() == 0 { + extras = append(extras, "dtype="+a.dtype.String()) + } + if len(extras) == 0 { + return prefix + lst + ")" } - switch a.dtype { + arr := prefix + lst + "," + extra := strings.Join(extras, ", ") + ")" + spacer := " " + if lastLine := len(arr) - (strings.LastIndexByte(arr, '\n') + 1); lastLine+len(extra)+1 > printWidth { + spacer = "\n" + strings.Repeat(" ", len(prefix)) + } + return arr + spacer + extra +} + +// dtypeImplied reports whether NumPy's repr leaves the dtype out because the +// printed values imply it. +func dtypeImplied(d DType) bool { + switch d { case Float64, Int64, Bool, Complex128: - default: - b.WriteString(", dtype=" + a.dtype.String()) + return true } - b.WriteByte(')') - return b.String() + return false } // Display shows the array in a notebook: its Repr as text, and as an HTML @@ -61,28 +77,31 @@ func (a *Array) Display() map[string][]byte { return out } -// format lays out the elements, the first line starting at column indent. -func (a *Array) format(indent int) string { - if len(a.shape) == 0 { - return a.element(a.contiguousStore(), 0) +// shownIndices lists the indices of an axis of length n that are printed, +// with -1 where the "..." goes. +func shownIndices(n int, summarise bool) []int { + if !summarise || n <= 2*printEdge { + out := make([]int, n) + for i := range out { + out[i] = i + } + return out } - flat := a.contiguousStore() - summarise := a.Size() > printThreshold - width := 0 - a.visit(summarise, func(i int) { - width = max(width, len(a.element(flat, i))) - }) - var b strings.Builder - a.formatAxis(&b, flat, 0, 0, indent, width, summarise) - return b.String() + out := []int{0, 1, 2, -1} + for i := n - printEdge; i < n; i++ { + out = append(out, i) + } + return out } -// visit calls f with the flat index of every element that is printed. -func (a *Array) visit(summarise bool, f func(int)) { +// shownFlat lists the flat (row-major) indices of the elements that are +// printed: NumPy's _leading_trailing. +func (a *Array) shownFlat(summarise bool) []int { + var out []int var walk func(axis, base int) walk = func(axis, base int) { if axis == len(a.shape) { - f(base) + out = append(out, base) return } stride := prod(a.shape[axis+1:]) @@ -93,115 +112,294 @@ func (a *Array) visit(summarise bool, f func(int)) { } } walk(0, 0) + return out } -// shownIndices lists the indices of an axis of length n that are printed, -// with -1 where the "..." goes. -func shownIndices(n int, summarise bool) []int { - if !summarise || n <= 2*printEdge { - out := make([]int, n) - for i := range out { - out[i] = i +// elementFormatter returns the function that writes element i of the +// contiguous storage, padded to the common width, built from the elements +// that are printed (NumPy's _get_format_function). +func (a *Array) elementFormatter() func(flat any, i int) string { + flat := a.contiguousStore() + shown := a.shownFlat(a.Size() > printThreshold) + switch v := flat.(type) { + case []float64: + f := newFloatFormat(pick(v, shown, func(x float64) float64 { return x }), 64, '-') + return func(s any, i int) string { return f.format(s.([]float64)[i]) } + case []float32: + f := newFloatFormat(pick(v, shown, func(x float32) float64 { return float64(x) }), 32, '-') + return func(s any, i int) string { return f.format(float64(s.([]float32)[i])) } + case []complex128: + c := newComplexFormat(pick(v, shown, func(z complex128) complex128 { return z }), 64) + return func(s any, i int) string { return c.format(s.([]complex128)[i]) } + case []complex64: + c := newComplexFormat(pick(v, shown, func(z complex64) complex128 { return complex128(z) }), 32) + return func(s any, i int) string { return c.format(complex128(s.([]complex64)[i])) } + case []bool: + // BoolFormat: " True" so that it lines up with "False", except in a + // 0-d array. + t := " True" + if len(a.shape) == 0 { + t = "True" + } + return func(s any, i int) string { + if s.([]bool)[i] { + return t + } + return "False" } - return out } - out := []int{0, 1, 2, -1} - for i := n - printEdge; i < n; i++ { - out = append(out, i) + // IntegerFormat: right-aligned to the longer of the largest and the + // smallest printed value. + width := 0 + for _, i := range shown { + width = max(width, len(elemString(flat, i))) + } + return func(s any, i int) string { + e := elemString(s, i) + return strings.Repeat(" ", width-len(e)) + e } - return out } -// formatAxis writes the sub-array at flat offset base starting at axis. -func (a *Array) formatAxis(b *strings.Builder, flat any, axis, base, indent, width int, summarise bool) { - b.WriteByte('[') - stride := prod(a.shape[axis+1:]) - idx := shownIndices(a.shape[axis], summarise) - last := axis == len(a.shape)-1 - col := indent + 1 // the column after the bracket +// pick converts the elements of v at the indices idx. +func pick[T, U any](v []T, idx []int, conv func(T) U) []U { + out := make([]U, len(idx)) for k, i := range idx { - if last { - s := "..." - if i >= 0 { - s = strings.Repeat(" ", width-len(a.element(flat, base+i))) + a.element(flat, base+i) + out[k] = conv(v[i]) + } + return out +} + +// formatArray lays the array out as NumPy's _formatArray does, with ", " as +// the separator; lineWidth already excludes the closing ")". +func (a *Array) formatArray(format func(any, int) string, lineWidth int, nextLinePrefix string) string { + flat := a.contiguousStore() + summarise := a.Size() > printThreshold + var rec func(axis, base int, hanging string, width int) string + rec = func(axis, base int, hanging string, width int) string { + if axis == len(a.shape) { + return format(flat, base) + } + nextHanging := hanging + " " + nextWidth := width - len("]") + stride := prod(a.shape[axis+1:]) + idx := shownIndices(a.shape[axis], summarise) + var s strings.Builder + if axis == len(a.shape)-1 { + elemWidth := width - max(len(","), len("]")) + line := hanging + for k, i := range idx { + if k > 0 { + line += ", " + } + word := "..." + if i >= 0 { + word = rec(axis+1, base+i*stride, nextHanging, nextWidth) + } + line = extendLine(&s, line, word, elemWidth, hanging) } - if k > 0 { - b.WriteByte(',') - col++ - if col+1+len(s)+1 > printWidth { - b.WriteString("\n" + strings.Repeat(" ", indent+1)) - col = indent + 1 - } else { - b.WriteByte(' ') - col++ + s.WriteString(line) + } else { + sep := "," + strings.Repeat("\n", len(a.shape)-axis-1) + for k, i := range idx { + if k > 0 { + s.WriteString(sep) + } + s.WriteString(hanging) + if i < 0 { + s.WriteString("...") + continue } + s.WriteString(rec(axis+1, base+i*stride, nextHanging, nextWidth)) } - b.WriteString(s) - col += len(s) + } + return "[" + s.String()[len(hanging):] + "]" + } + return rec(0, 0, nextLinePrefix, lineWidth) +} + +// extendLine is NumPy's _extendLine: start a new line when the word would +// pass the width, unless the line holds nothing but its prefix (a wrap that +// would not help). +func extendLine(s *strings.Builder, line, word string, width int, prefix string) string { + if len(line)+len(word) > width && len(line) > len(prefix) { + s.WriteString(strings.TrimRight(line, " ") + "\n") + line = prefix + } + return line + word +} + +// floatFormat is NumPy's FloatingFormat in "maxprec" mode with precision 8. +type floatFormat struct { + bits int // 64 or 32: the shortest digits are those of this type + sign byte // '-' (only negatives) or '+' (always) + exp bool // scientific notation + precision int // fraction digits: the cut-off, or the common width in exp mode + minDigits int // fraction digits padded with zeros up to this + padLeft, padRight int + expDigits int + nonFinites bool +} + +func newFloatFormat(vals []float64, bits int, sign byte) *floatFormat { + f := &floatFormat{bits: bits, sign: sign, precision: printPrecision} + var finite []float64 + maxAbs, minAbs := 0.0, math.Inf(1) + for _, x := range vals { + if math.IsInf(x, 0) || math.IsNaN(x) { + f.nonFinites = true continue } - if k > 0 { - b.WriteString("," + strings.Repeat("\n", len(a.shape)-axis-1) + strings.Repeat(" ", indent+1)) + finite = append(finite, x) + if x != 0 { + maxAbs, minAbs = max(maxAbs, math.Abs(x)), min(minAbs, math.Abs(x)) } - if i < 0 { - b.WriteString("...") - continue + } + if maxAbs > 0 { + // The cut-off is 10**min(8, finfo(dtype).precision): 1e8 for + // float64, 1e6 for float32 (whose decimal precision is 6). + cutoff := 1e8 + if bits == 32 { + cutoff = 1e6 + } + f.exp = maxAbs >= cutoff || minAbs < 1e-4 || maxAbs/minAbs > 1000 + } + f.expDigits = -1 + switch { + case len(finite) == 0: + case f.exp: + // First pass: unique digits, trimmed, to learn the widths. + for _, x := range finite { + intPart, frac, e := f.scientific(x, printPrecision, 0, false) + f.padLeft = max(f.padLeft, len(intPart)) + f.minDigits = max(f.minDigits, len(frac)) + f.expDigits = max(f.expDigits, len(e)-1) + } + f.precision = f.minDigits + f.padRight = f.expDigits + 2 + f.precision + default: + for _, x := range finite { + intPart, frac := f.positional(x) + f.padLeft = max(f.padLeft, len(intPart)) + f.padRight = max(f.padRight, len(frac)) } - a.formatAxis(b, flat, axis+1, base+i*stride, indent+1, width, summarise) } - b.WriteByte(']') + if f.nonFinites { + neginf := 0 + for _, x := range vals { + if sign != '-' || math.IsInf(x, -1) { + neginf = 1 + } + } + offset := f.padRight + 1 + f.padLeft = max(f.padLeft, len("nan")-offset, len("inf")+neginf-offset) + } + return f } -// element writes element i of a contiguous storage slice for printing: -// integral floats end in a point ("2."), as in NumPy. -func (a *Array) element(flat any, i int) string { - s := elemString(flat, i) - switch v := flat.(type) { - case []float64: - return pointed(s, v[i]) - case []float32: - return pointed(s, float64(v[i])) - case []bool: - if v[i] { - return "True" - } - return "False" - case []complex64: - return complexElement(complex128(v[i]), 32) - case []complex128: - return complexElement(v[i], 64) +// signed writes the sign NumPy's dragon4 writes: "-" for a negative value +// (negative zero included), "+" for any other when the mode is '+'. +func (f *floatFormat) signed(x float64) (string, float64) { + if math.Signbit(x) { + return "-", -x + } + if f.sign == '+' { + return "+", x } - return s + return "", x } -// complexElement writes a complex element as NumPy does inside an array: -// 1.+2.j, no parentheses. -func complexElement(z complex128, bits int) string { - re, im := real(z), imag(z) - sign := "+" - if im < 0 || (im == 0 && math.Signbit(im)) { - sign, im = "-", -im - } - return pointed(floatString(re, bits), re) + sign + pointed(floatString(im, bits), im) + "j" +// positional returns the integer part (with its sign) and the fraction +// digits of x: the shortest digits that read back as x, cut to the precision +// (rounded half to even on the exact value), trailing zeros trimmed. +func (f *floatFormat) positional(x float64) (string, string) { + sign, x := f.signed(x) + s := strconv.FormatFloat(x, 'f', -1, f.bits) + if dot := strings.IndexByte(s, '.'); dot >= 0 && len(s)-dot-1 > f.precision { + s = strings.TrimRight(strconv.FormatFloat(x, 'f', f.precision, f.bits), "0") + } + intPart, frac, _ := strings.Cut(s, ".") + return sign + intPart, frac } -// pointed adds NumPy's trailing point to an integral float written without -// an exponent. -func pointed(s string, v float64) string { - if v == math.Trunc(v) && !math.IsInf(v, 0) && !strings.ContainsAny(s, "e.") { - return s + "." +// scientific returns the mantissa's integer digit (with its sign), its +// fraction digits and the exponent ("+05"): unique digits cut to precision, +// trimmed, then padded with zeros to minDigits; the exponent has at least +// two digits, or expDigits when pad is set. +func (f *floatFormat) scientific(x float64, precision, minDigits int, pad bool) (string, string, string) { + sign, x := f.signed(x) + s := strconv.FormatFloat(x, 'e', -1, f.bits) + mant, e, _ := strings.Cut(s, "e") + if _, frac, _ := strings.Cut(mant, "."); len(frac) > precision { + mant, e, _ = strings.Cut(strconv.FormatFloat(x, 'e', precision, f.bits), "e") + mant = strings.TrimRight(mant, "0") } - return s + intPart, frac, _ := strings.Cut(mant, ".") + frac += strings.Repeat("0", max(0, minDigits-len(frac))) + digits := strings.TrimLeft(e[1:], "0") + width := 2 + if pad { + width = max(width, min(f.expDigits, 5)) + } + digits = strings.Repeat("0", max(0, width-len(digits))) + digits + return sign + intPart, frac, e[:1] + digits +} + +// format writes x padded to the format's width. +func (f *floatFormat) format(x float64) string { + if math.IsNaN(x) || math.IsInf(x, 0) { + var r string + switch { + case math.IsNaN(x): + r = "nan" + if f.sign == '+' { + r = "+nan" + } + case x < 0: + r = "-inf" + case f.sign == '+': + r = "+inf" + default: + r = "inf" + } + return strings.Repeat(" ", max(0, f.padLeft+f.padRight+1-len(r))) + r + } + if f.exp { + intPart, frac, e := f.scientific(x, f.precision, f.minDigits, true) + return strings.Repeat(" ", max(0, f.padLeft-len(intPart))) + intPart + "." + frac + "e" + e + } + intPart, frac := f.positional(x) + return strings.Repeat(" ", max(0, f.padLeft-len(intPart))) + intPart + "." + frac + strings.Repeat(" ", max(0, f.padRight-len(frac))) +} + +// complexFormat is NumPy's ComplexFloatingFormat: the real parts and the +// imaginary parts each get their own FloatingFormat, the imaginary one +// always signed, and the "j" goes before the imaginary part's padding. +type complexFormat struct{ re, im *floatFormat } + +func newComplexFormat(vals []complex128, bits int) *complexFormat { + re := make([]float64, len(vals)) + im := make([]float64, len(vals)) + for i, z := range vals { + re[i], im[i] = real(z), imag(z) + } + return &complexFormat{newFloatFormat(re, bits, '-'), newFloatFormat(im, bits, '+')} +} + +func (c *complexFormat) format(z complex128) string { + i := c.im.format(imag(z)) + sp := len(strings.TrimRight(i, " ")) + return c.re.format(real(z)) + i[:sp] + "j" + i[sp:] } // htmlTable writes a one- or two-dimensional array as a table, summarised -// like the text form. +// like the text form, each cell the element as Repr writes it, unpadded. func (a *Array) htmlTable() string { rows, cols := 1, a.shape[0] if len(a.shape) == 2 { rows, cols = a.shape[0], a.shape[1] } flat := a.contiguousStore() + format := a.elementFormatter() summarise := a.Size() > printThreshold var b strings.Builder b.WriteString(`") continue } - b.WriteString("") + b.WriteString("") } b.WriteString("") } diff --git a/display_numpy_test.go b/display_numpy_test.go new file mode 100644 index 0000000..8033598 --- /dev/null +++ b/display_numpy_test.go @@ -0,0 +1,165 @@ +package ndarray + +import ( + "math" + "strconv" + "testing" +) + +// TestReprMatchesNumPysOwnTests checks Repr against the expected strings of +// NumPy's own test suite, numpy/_core/tests/test_arrayprint.py at tag v2.5.3, +// for every case that uses the default print options and a dtype this package +// has (no float16, no structured or string arrays). The strings are copied +// verbatim; the test each comes from is named beside it. +func TestReprMatchesNumPysOwnTests(t *testing.T) { + f64 := func(v ...float64) *Array { return must(FromData(v, len(v))) } + scalar := func(v float64) *Array { return must(FromData([]float64{v})) } + nan, inf := math.NaN(), math.Inf(1) + // test_floatmode: y, the float64 case, and w, built from the strings + // "1e0" ... "1e24" (1e23 lies between two float64 values). + y := f64(0.2918820979355541, 0.5064172631089138, 0.2848750619642916, 0.4342965294660567, + 0.7326538397312751, 0.3459503329096204, 0.0862072768214508, 0.39112753029631175) + var w []float64 + for i := range 25 { + v, err := strconv.ParseFloat("1e"+strconv.Itoa(i), 64) + if err != nil { + t.Fatal(err) + } + w = append(w, v) + } + c := must(FromSlice([]complex128{1 + 1i, 1.123456789 + 1.123456789i}, 2)) + cases := []struct { + test string + a *Array + want string + }{ + {"test_nan_inf", f64(nan, inf), "array([nan, inf])"}, + {"test_float_spacing", f64(1, 2, 3), "array([1., 2., 3.])"}, + {"test_float_spacing", f64(1, 2, -10), "array([ 1., 2., -10.])"}, + {"test_float_spacing", scalar(1), "array(1.)"}, + {"test_float_spacing", scalar(-10), "array(-10.)"}, + {"test_float_spacing", f64(100, 2, -1), "array([100., 2., -1.])"}, + {"test_float_spacing", f64(-100, 2, 1), "array([-100., 2., 1.])"}, + {"test_float_spacing", f64(nan, -inf), "array([ nan, -inf])"}, + {"test_bool_spacing", must(FromSlice([]bool{true, true}, 2)), "array([ True, True])"}, + {"test_bool_spacing", must(FromSlice([]bool{true, false}, 2)), "array([ True, False])"}, + {"test_bool_spacing", must(FromSlice([]bool{true}, 1)), "array([ True])"}, + {"test_bool_spacing", must(FromSlice([]bool{true})), "array(True)"}, + {"test_bool_spacing", must(FromSlice([]bool{false})), "array(False)"}, + {"test_sign_spacing", must(Arange(0, 4, 1)), "array([0., 1., 2., 3.])"}, + {"test_sign_spacing", f64(1.234e9), "array([1.234e+09])"}, + {"test_sign_spacing", f64(0), "array([0.])"}, + {"test_sign_spacing", c, "array([1. +1.j , 1.12345679+1.12345679j])"}, + {"test_sign_spacing", f64(0, math.Copysign(0, -1)), "array([ 0., -0.])"}, + {"test_floatmode (maxprec)", y, "array([0.2918821 , 0.50641726, 0.28487506, 0.43429653, 0.73265384,\n" + + " 0.34595033, 0.08620728, 0.39112753])"}, + {"test_floatmode (maxprec)", f64(w[0], w[5], w[10], w[15], w[20]), "array([1.e+00, 1.e+05, 1.e+10, 1.e+15, 1.e+20])"}, + {"test_floatmode (maxprec)", f64(1.234e1, 1e2, 1e123), "array([1.234e+001, 1.000e+002, 1.000e+123])"}, + // The unique-mode string for w: maxprec gives the same digits for + // powers of ten, so this checks the wrapping at 75 columns. + {"test_floatmode (w)", f64(w...), "array([1.e+00, 1.e+01, 1.e+02, 1.e+03, 1.e+04, 1.e+05, 1.e+06, 1.e+07,\n" + + " 1.e+08, 1.e+09, 1.e+10, 1.e+11, 1.e+12, 1.e+13, 1.e+14, 1.e+15,\n" + + " 1.e+16, 1.e+17, 1.e+18, 1.e+19, 1.e+20, 1.e+21, 1.e+22, 1.e+23,\n" + + " 1.e+24])"}, + {"test_summarize_1d", must(Arange(0, 1001, 1)).AsType(Int64), "array([ 0, 1, 2, ..., 998, 999, 1000], shape=(1001,))"}, + {"test_summarize_2d", must(must(Arange(0, 1002, 1)).AsType(Int64).Reshape(2, 501)), + "array([[ 0, 1, 2, ..., 498, 499, 500],\n" + + " [ 501, 502, 503, ..., 999, 1000, 1001]], shape=(2, 501))"}, + {"test_summarize_2d_dtype", must(must(Arange(0, 1002, 1)).AsType(Int16).Reshape(2, 501)), + "array([[ 0, 1, 2, ..., 498, 499, 500],\n" + + " [ 501, 502, 503, ..., 999, 1000, 1001]],\n" + + " shape=(2, 501), dtype=int16)"}, + } + for _, tc := range cases { + if got := tc.a.Repr(); got != tc.want { + t.Errorf("%s:\n got %q\nwant %q", tc.test, got, tc.want) + } + } +} + +// TestComplexSpecialValuesMatchNumPy is TestComplexArray.test_str (NumPy +// v2.5.3): every pair of 0, 1, -1, inf, -inf, nan as real and imaginary part, +// in a one-element array, for complex64 and complex128. NumPy's expected +// strings are str() forms, "[...]"; for one element repr shows the same +// between "array(" and ")". +func TestComplexSpecialValuesMatchNumPy(t *testing.T) { + r := []float64{0, 1, -1, math.Inf(1), math.Inf(-1), math.NaN()} + wanted := []string{ + "[0.+0.j]", "[0.+1.j]", "[0.-1.j]", "[0.+infj]", "[0.-infj]", "[0.+nanj]", + "[1.+0.j]", "[1.+1.j]", "[1.-1.j]", "[1.+infj]", "[1.-infj]", "[1.+nanj]", + "[-1.+0.j]", "[-1.+1.j]", "[-1.-1.j]", "[-1.+infj]", "[-1.-infj]", "[-1.+nanj]", + "[inf+0.j]", "[inf+1.j]", "[inf-1.j]", "[inf+infj]", "[inf-infj]", "[inf+nanj]", + "[-inf+0.j]", "[-inf+1.j]", "[-inf-1.j]", "[-inf+infj]", "[-inf-infj]", "[-inf+nanj]", + "[nan+0.j]", "[nan+1.j]", "[nan-1.j]", "[nan+infj]", "[nan-infj]", "[nan+nanj]", + } + k := 0 + for _, re := range r { + for _, im := range r { + want := "array(" + wanted[k] + ")" + if got := must(FromSlice([]complex128{complex(re, im)}, 1)).Repr(); got != want { + t.Errorf("complex128(%v, %v): got %q, want %q", re, im, got, want) + } + want = "array(" + wanted[k] + ", dtype=complex64)" + if got := must(FromSlice([]complex64{complex64(complex(re, im))}, 1)).Repr(); got != want { + t.Errorf("complex64(%v, %v): got %q, want %q", re, im, got, want) + } + k++ + } + } +} + +// TestReprRulesWithoutANumPyCase covers the paths NumPy's suite has no +// default-options case for, with the expectation worked out from the rules of +// arrayprint.py v2.5.3 (each stated beside it). +func TestReprRulesWithoutANumPyCase(t *testing.T) { + for _, tc := range []struct { + a *Array + want string + rule string + }{ + {must(Zeros(0)), "array([], dtype=float64)", + "an empty array prints [] and always its dtype; shape (0,) is implied"}, + {must(Zeros(0, 3)), "array([], shape=(0, 3), dtype=float64)", + "an empty array of any other shape also prints its shape"}, + {must(FromData([]float64{1.123456789e9}, 1)), "array([1.12345679e+09])", + "scientific mantissas are cut to 8 fraction digits, rounded"}, + {must(FromData([]float64{-1e-5, 2}, 2)), "array([-1.e-05, 2.e+00])", + "min |x| < 1e-4 switches to scientific; the sign widens the integer part"}, + } { + if got := tc.a.Repr(); got != tc.want { + t.Errorf("%s:\n got %q\nwant %q", tc.rule, got, tc.want) + } + } +} + +// TestElementsMatchNumPyGh28679 checks the elements of test_gh_28679 (NumPy +// v2.5.3), whose expected strings are str() forms: the elements are the same +// as repr's, only the separator differs, so each element is compared alone. +// They pin the exponent cut-off that depends on the dtype: 1e6 for float32. +func TestElementsMatchNumPyGh28679(t *testing.T) { + elements := func(a *Array) []string { + f := a.elementFormatter() + flat := a.contiguousStore() + out := make([]string, a.Size()) + for i := range out { + out[i] = f(flat, i) + } + return out + } + for _, tc := range []struct { + a *Array + want []string + }{ + {must(FromSlice([]float32{999999, 999999}, 2)), []string{"999999.", "999999."}}, + {must(FromSlice([]float32{999999, -1000000}, 2)), []string{" 9.99999e+05", "-1.00000e+06"}}, + {must(FromSlice([]complex64{999999 + 999999i, 999999 + 999999i}, 2)), []string{"999999.+999999.j", "999999.+999999.j"}}, + {must(FromSlice([]complex64{999999 + 999999i, complex(999999, -1000000)}, 2)), []string{"999999.+9.99999e+05j", "999999.-1.00000e+06j"}}, + } { + got := elements(tc.a) + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("%v: element %d = %q, want %q", tc.a.dtype, i, got[i], tc.want[i]) + } + } + } +} diff --git a/marshal_test.go b/marshal_test.go index a775782..b48b251 100644 --- a/marshal_test.go +++ b/marshal_test.go @@ -102,13 +102,17 @@ func TestRepr(t *testing.T) { }{ {must(FromSlice([]int64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}, 3, 4)), "array([[ 0, 1, 2, 3],\n [ 4, 5, 6, 7],\n [ 8, 9, 10, 11]])"}, - {must(FromSlice([]float32{1.5, 2, 3}, 3)), "array([1.5, 2., 3.], dtype=float32)"}, + // NumPy pads every fraction to the longest one, with spaces. + {must(FromSlice([]float32{1.5, 2, 3}, 3)), "array([1.5, 2. , 3. ], dtype=float32)"}, {must(FromSlice([]complex128{1 + 2i, complex(0, -1)}, 2)), "array([1.+2.j, 0.-1.j])"}, {must(FromSlice([]complex64{complex(0, float32(negZero()))}, 1)), "array([0.-0.j], dtype=complex64)"}, {must(FromSlice([]bool{true, false}, 2)), "array([ True, False])"}, {must(FromSlice([]uint8{7}, []int{}...)), "array(7, dtype=uint8)"}, - {must(FromSlice([]float64{1e20, 0.5}, 2)), "array([1e+20, 0.5])"}, - {big, "array([ 0., 1., 2., ...,"}, + // |max| >= 1e8: scientific for every element, as in NumPy. + {must(FromSlice([]float64{1e20, 0.5}, 2)), "array([1.e+20, 5.e-01])"}, + // The printed values 0..2 and 1997..1999 have a max/min ratio over + // 1000, so NumPy prints them in scientific notation too. + {big, "array([0.000e+00, 1.000e+00, 2.000e+00, ..., 1.997e+03, 1.998e+03,\n 1.999e+03], shape=(2000,))"}, {must(big.Reshape(40, 50)), " ...,\n"}, {must(big.Reshape(40, 50)), "shape=(40, 50))"}, {big, "shape=(2000,))"}, @@ -140,7 +144,7 @@ func TestDisplay(t *testing.T) { t.Errorf("1-D html:\n%s", h) } big := must(must(Arange(0, 4000, 1)).Reshape(40, 100)) - if h := string(big.Display()["text/html"]); !strings.Contains(h, "") || !strings.Contains(h, "") || !strings.Contains(h, "") { + if h := string(big.Display()["text/html"]); !strings.Contains(h, "") || !strings.Contains(h, "") || !strings.Contains(h, "") { t.Errorf("summarised html") } if _, ok := must(Zeros(2, 2, 2)).Display()["text/html"]; ok {
`) @@ -226,7 +424,7 @@ func (a *Array) htmlTable() string { b.WriteString("
⋯" + html.EscapeString(a.element(flat, i*cols+j)) + "" + html.EscapeString(strings.TrimSpace(format(flat, i*cols+j))) + "
⋯⋯3999.⋯⋯3.999e+03