Repository: astropy/astropy

Issue:
TypeError on write when stacking an empty table with a table that has a Time column
### Description

I need to `vstack` an arbitrary number of tables, that could be 1 or more. I create an empty table and then `vstack` to it all the tables I have read (`astropy` tables stored as `ecsv`) and then write back a merged table to an `ecsv` file. This was working fine with `astropy` 5.X, however crashes now with the following error:
```
TypeError: Iterator operand 1 dtype could not be cast from dtype([('year', '?'), ('month', '?'), ('day', '?'), ('hour', '?'), ('minute', '?'), ('second', '?')]) to dtype('bool') according to the rule 'unsafe'
```
However, if I `vstack` two non-empty two non-empty tables, the writing still works.

### Expected behavior

The code shall not crash and the Time type shall be converted (decomposed) to `ecsv` schema properly.

### How to Reproduce

Install astropy-6.X, then the following code snippet can be used to reproduce:

```python
>>> from astropy.table import Table, vstack
>>> test_data = Table.read('/tmp/meteo_data/copernicus_0.ecsv')
>>> test_data.info
<Table length=1036>
          name           dtype       unit     class
----------------------- ------- ------------- ------
               Latitude float64           deg Column
              Longitude float64           deg Column
              Timestamp  object                 Time
               Pressure float64           hPa Column
           Geopotential float64       m2 / s2 Column
Ozone mass mixing ratio float64               Column
    Potential vorticity float64 m2 K / (kg s) Column
      Relative humidity float64             % Column
            Temperature float64             K Column
    U component of wind float64         m / s Column
    V component of wind float64         m / s Column
      Vertical velocity float64        Pa / s Column
             Divergence float64         1 / s Column
               Altitude float64             m Column
                Density float64       1 / cm3 Column
    Exponential Density float64               Column
             Wind Speed float64         m / s Column
         Wind Direction float64           rad Column

>>> test_data.write('/tmp/meteo_data/test_data_write.ecsv')
>>> empty_table = Table()
>>> merged_table = vstack([empty_table, test_data])
>>> merged_table.info
<Table length=1036>
          name           dtype       unit        class
----------------------- ------- ------------- ------------
               Latitude float64           deg MaskedColumn
              Longitude float64           deg MaskedColumn
              Timestamp  object                       Time
               Pressure float64           hPa MaskedColumn
           Geopotential float64       m2 / s2 MaskedColumn
Ozone mass mixing ratio float64               MaskedColumn
    Potential vorticity float64 m2 K / (kg s) MaskedColumn
      Relative humidity float64             % MaskedColumn
            Temperature float64             K MaskedColumn
    U component of wind float64         m / s MaskedColumn
    V component of wind float64         m / s MaskedColumn
      Vertical velocity float64        Pa / s MaskedColumn
             Divergence float64         1 / s MaskedColumn
               Altitude float64             m MaskedColumn
                Density float64       1 / cm3 MaskedColumn
    Exponential Density float64               MaskedColumn
             Wind Speed float64         m / s MaskedColumn
         Wind Direction float64           rad MaskedColumn

>>> merged_table.write('/tmp/meteo_data/test_merged_data_write.ecsv')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/opt/mambaforge/envs/calibpipe/lib/python3.12/site-packages/astropy/table/connect.py", line 130, in __call__
    self.registry.write(instance, *args, **kwargs)
  File "/opt/mambaforge/envs/calibpipe/lib/python3.12/site-packages/astropy/io/registry/core.py", line 386, in write
    return writer(data, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/mambaforge/envs/calibpipe/lib/python3.12/site-packages/astropy/io/ascii/connect.py", line 28, in io_write
    return write(table, filename, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/mambaforge/envs/calibpipe/lib/python3.12/site-packages/astropy/utils/decorators.py", line 604, in wrapper
    return function(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/mambaforge/envs/calibpipe/lib/python3.12/site-packages/astropy/io/ascii/ui.py", line 1036, in write
    lines = writer.write(table)
            ^^^^^^^^^^^^^^^^^^^
  File "/opt/mambaforge/envs/calibpipe/lib/python3.12/site-packages/astropy/io/ascii/core.py", line 1562, in write
    table = self.update_table_data(table)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/mambaforge/envs/calibpipe/lib/python3.12/site-packages/astropy/io/ascii/ecsv.py", line 518, in update_table_data
    out = serialize.represent_mixins_as_columns(table)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/mambaforge/envs/calibpipe/lib/python3.12/site-packages/astropy/table/serialize.py", line 295, in represent_mixins_as_columns
    _represent_mixin_as_column(
  File "/opt/mambaforge/envs/calibpipe/lib/python3.12/site-packages/astropy/table/serialize.py", line 209, in _represent_mixin_as_column
    _represent_mixin_as_column(data, new_name, new_cols, obj_attrs)
  File "/opt/mambaforge/envs/calibpipe/lib/python3.12/site-packages/astropy/table/serialize.py", line 196, in _represent_mixin_as_column
    if (hasattr(data, "mask") and np.any(data.mask))
                                  ^^^^^^^^^^^^^^^^^
  File "/opt/mambaforge/envs/calibpipe/lib/python3.12/site-packages/numpy/core/fromnumeric.py", line 2412, in any
    return _wrapreduction(a, np.logical_or, 'any', axis, None, out,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/mambaforge/envs/calibpipe/lib/python3.12/site-packages/numpy/core/fromnumeric.py", line 88, in _wrapreduction
    return ufunc.reduce(obj, axis, dtype, out, **passkwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: Iterator operand 1 dtype could not be cast from dtype([('year', '?'), ('month', '?'), ('day', '?'), ('hour', '?'), ('minute', '?'), ('second', '?')]) to dtype('bool') according to the rule 'unsafe'
>>> another_test_data_instance = Table.read('/tmp/meteo_data/copernicus_0.ecsv')
>>> merged_table_with_non_empty = vstack([another_test_data_instance, test_data])
>>> merged_table_with_non_empty.info
<Table length=2072>
          name           dtype       unit     class
----------------------- ------- ------------- ------
               Latitude float64           deg Column
              Longitude float64           deg Column
              Timestamp  object                 Time
               Pressure float64           hPa Column
           Geopotential float64       m2 / s2 Column
Ozone mass mixing ratio float64               Column
    Potential vorticity float64 m2 K / (kg s) Column
      Relative humidity float64             % Column
            Temperature float64             K Column
    U component of wind float64         m / s Column
    V component of wind float64         m / s Column
      Vertical velocity float64        Pa / s Column
             Divergence float64         1 / s Column
               Altitude float64             m Column
                Density float64       1 / cm3 Column
    Exponential Density float64               Column
             Wind Speed float64         m / s Column
         Wind Direction float64           rad Column

>>> merged_table_with_non_empty.write('/tmp/meteo_data/merged_table_with_non_empty_write.ecsv')
```

An example `ecsv` table is attached (just rename txt to ecsv, GitHub does not allow ecsv attachments).
[copernicus_0.txt](https://github.com/astropy/astropy/files/15187797/copernicus_0.txt)



### Versions

```python
import platform; print(platform.platform())
import sys; print("Python", sys.version)
import astropy; print("astropy", astropy.__version__)
import numpy; print("Numpy", numpy.__version__)
import erfa; print("pyerfa", erfa.__version__)
try:
    import scipy
    print("Scipy", scipy.__version__)
except ImportError:
    print("Scipy not installed")
try:
    import matplotlib
    print("Matplotlib", matplotlib.__version__)
except ImportError:
    print("Matplotlib not installed")
```
```
macOS-13.5.1-x86_64-i386-64bit
Python 3.12.3 | packaged by conda-forge | (main, Apr 15 2024, 18:50:49) [Clang 16.0.6 ]
astropy 6.0.1
Numpy 1.26.4
pyerfa 2.0.1.4
Scipy 1.13.0
Matplotlib 3.8.4
```

Also, the same error is reproduced in a number of platforms in CI. See here for the reference:
https://gitlab.cta-observatory.org/cta-computing/dpps/calibrationpipeline/calibpipe/-/jobs/183758

Discussion:

Patch:
diff --git a/astropy/table/column.py b/astropy/table/column.py
index 121f87cc53..e8812ee0a5 100644
--- a/astropy/table/column.py
+++ b/astropy/table/column.py
@@ -396,7 +396,7 @@ class ColumnInfo(BaseColumnInfo):
         else:
             units = [None] * len(names)
         for name, part_unit in zip(names, units):
-            part = Column(self._parent[name])
+            part = self._parent.__class__(self._parent[name])
             part.unit = part_unit
             part.description = None
             part.meta = {}
diff --git a/astropy/table/serialize.py b/astropy/table/serialize.py
index 8ef83dadf0..96b4ce6d0a 100644
--- a/astropy/table/serialize.py
+++ b/astropy/table/serialize.py
@@ -38,6 +38,7 @@ __construct_mixin_classes = (
     "astropy.table.column.Column",
     "astropy.table.column.MaskedColumn",
     "astropy.utils.masked.core.MaskedNDArray",
+    "astropy.utils.masked.core.MaskedRecarray",
     # Angles
     "astropy.coordinates.angles.core.Latitude",
     "astropy.coordinates.angles.core.Longitude",
@@ -193,7 +194,10 @@ def _represent_mixin_as_column(col, name, new_cols, mixin_cols, exclude_classes=
         if not has_info_class(data, MixinInfo):
             col_cls = (
                 MaskedColumn
-                if (hasattr(data, "mask") and np.any(data.mask))
+                if (
+                    hasattr(data, "mask")
+                    and np.any(data.mask != np.zeros((), data.mask.dtype))
+                )
                 else Column
             )
             data = col_cls(data, name=new_name, **new_info)
diff --git a/astropy/table/table.py b/astropy/table/table.py
index acab8322f0..69684c76d1 100644
--- a/astropy/table/table.py
+++ b/astropy/table/table.py
@@ -1682,7 +1682,10 @@ class Table:
         This may be relatively slow for large tables as it requires checking the mask
         values of each column.
         """
-        return any(hasattr(col, "mask") and np.any(col.mask) for col in self.itercols())
+        return any(
+            hasattr(col, "mask") and np.any(col.mask != np.zeros((), col.mask.dtype))
+            for col in self.itercols()
+        )
 
     def _is_mixin_for_table(self, col):
         """
diff --git a/astropy/utils/masked/core.py b/astropy/utils/masked/core.py
index 70da59fae8..5f4ddd8726 100644
--- a/astropy/utils/masked/core.py
+++ b/astropy/utils/masked/core.py
@@ -1310,9 +1310,18 @@ class MaskedNDArray(Masked, np.ndarray, base_cls=np.ndarray, data_cls=np.ndarray
         return hash((self.unmasked, self.mask))
 
 
+class MaskedRecarrayInfo(MaskedNDArrayInfo):
+    # Ensure that we output a plain MaskedArray, not a masked_recarray.
+    def _represent_as_dict(self):
+        masked_ndarray = self._parent.view(np.ndarray)
+        return masked_ndarray.info._represent_as_dict()
+
+
 class MaskedRecarray(np.recarray, MaskedNDArray, data_cls=np.recarray):
     # Explicit definition since we need to override some methods.
 
+    info = MaskedRecarrayInfo()
+
     def __array_finalize__(self, obj):
         # recarray.__array_finalize__ does not do super, so we do it
         # explicitly.
@@ -1335,3 +1344,11 @@ class MaskedRecarray(np.recarray, MaskedNDArray, data_cls=np.recarray):
                 return
 
         raise NotImplementedError("can only set existing field from structured dtype.")
+
+    def __repr__(self):
+        cls_name = type(self).__name__
+        out = super().__repr__().splitlines()
+        prefix, _, rest = out[0].partition("(")
+        out0 = cls_name + "(" + rest
+        extra_space = (len(cls_name) - len(prefix)) * " "
+        return "\n".join([out0] + [extra_space + o for o in out[1:]])
diff --git a/docs/changes/table/16380.bugfix.rst b/docs/changes/table/16380.bugfix.rst
new file mode 100644
index 0000000000..2395df798d
--- /dev/null
+++ b/docs/changes/table/16380.bugfix.rst
@@ -0,0 +1,2 @@
+Ensure structured ``MaskedColumn`` are serialized correctly, including
+the mask.
diff --git a/docs/changes/time/16380.bugfix.rst b/docs/changes/time/16380.bugfix.rst
new file mode 100644
index 0000000000..6b85056a49
--- /dev/null
+++ b/docs/changes/time/16380.bugfix.rst
@@ -0,0 +1,2 @@
+Ensure Time in ymdhms format can also be serialized to files as part of a
+table if it is masked.
diff --git a/docs/changes/utils/16380.bugfix.rst b/docs/changes/utils/16380.bugfix.rst
new file mode 100644
index 0000000000..11b0164755
--- /dev/null
+++ b/docs/changes/utils/16380.bugfix.rst
@@ -0,0 +1,3 @@
+Ensure Masked versions of ``np.recarray`` will show the correct class
+name of ``MaskedRecarray`` in their ``repr``, and that they will be
+serialized correctly if part of a table.


Test patch:
diff --git a/astropy/io/ascii/tests/test_ecsv.py b/astropy/io/ascii/tests/test_ecsv.py
index 3963435bcb..db25c37a10 100644
--- a/astropy/io/ascii/tests/test_ecsv.py
+++ b/astropy/io/ascii/tests/test_ecsv.py
@@ -22,8 +22,10 @@ from astropy.io.tests.mixin_columns import compare_attrs, mixin_cols, serialized
 from astropy.table import Column, QTable, Table
 from astropy.table.column import MaskedColumn
 from astropy.table.table_helpers import simple_table
+from astropy.time import Time
 from astropy.units import QuantityInfo
 from astropy.units import allclose as quantity_allclose
+from astropy.utils.masked import Masked
 
 from .common import TEST_DIR
 
@@ -1076,3 +1078,32 @@ def test_guess_ecsv_with_one_column():
     t = ascii.read(txt)
     assert t["col"].dtype.kind == "U"  # would be int with basic format
     assert t["col"].description == "hello"
+
+
+@pytest.mark.parametrize("masked", [MaskedColumn, Masked, np.ma.MaskedArray])
+def test_write_structured_masked_column(masked):
+    a = np.array([(1, 2), (3, 4)], dtype="i,i")
+    mc = masked(a, mask=[(True, False), (False, False)])
+    t = Table([mc], names=["mc"])
+    out = StringIO()
+    t.write(out, format="ascii.ecsv")
+    t2 = Table.read(out.getvalue(), format="ascii.ecsv")
+    assert type(t2["mc"]) is type(t["mc"])
+    assert (t2["mc"] == mc).all()
+    assert (t2["mc"].mask == mc.mask).all()
+
+
+def test_write_masked_time_ymdhms_mixin():
+    # Regression test for gh-16370
+    # Make a masked time,
+    t = Time({"year": 2000, "month": 1, "day": [1, 2]})
+    t[0] = np.ma.masked
+    # Create a table and write to a file
+    qt = QTable([t], names=["t"])
+    out = StringIO()
+    qt.write(out, format="ascii.ecsv")
+    # Read back and compare.
+    qt2 = QTable.read(out.getvalue(), format="ascii.ecsv")
+    # Note that value under time does not roundtrip
+    assert (qt2["t"] == t).all()
+    assert (qt2["t"].mask == t.mask).all()
diff --git a/astropy/utils/masked/tests/test_masked.py b/astropy/utils/masked/tests/test_masked.py
index b7ae99dcd5..eac6e10dcf 100644
--- a/astropy/utils/masked/tests/test_masked.py
+++ b/astropy/utils/masked/tests/test_masked.py
@@ -1502,6 +1502,22 @@ class TestMaskedRecarray(MaskedArraySetup):
         assert_array_equal(mra.a.unmasked, self.msa["b"].unmasked)
         assert_array_equal(mra.a.mask, self.msa["b"].mask)
 
+    def test_recarray_repr(self):
+        assert repr(self.mra) == (
+            "MaskedRecarray([[(———, ———), ( 3.,  4.)],\n"
+            "                [(11., ———), (———, 14.)]],\n"
+            "               dtype=[('a', '<f8'), ('b', '<f8')])"
+        )
+
+    def test_recarray_represent_as_dict(self):
+        rasd = self.mra.info._represent_as_dict()
+        assert type(rasd["data"]) is np.ma.MaskedArray
+        assert type(rasd["data"].base) is np.ndarray
+        mra2 = type(self.mra).info._construct_from_dict(rasd)
+        assert type(mra2) is type(self.mra)
+        assert_array_equal(mra2.unmasked, self.ra)
+        assert_array_equal(mra2.mask, self.mra.mask)
+
 
 class TestMaskedArrayInteractionWithNumpyMA(MaskedArraySetup):
     def test_masked_array_from_masked(self):