Skip to content

Commit adca707

Browse files
committed
more tests, rounding bug fix
1 parent 987ff4b commit adca707

3 files changed

Lines changed: 134 additions & 2 deletions

File tree

src/hecdss/dss_csv.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,8 @@ def paired_data_to_csv(paired_data: PairedData, path: str, with_metadata: bool)
184184
x: float # x is the same as "ordinate"
185185
y_row: list[float] # Each y_row is one row of y_values, as paired_data.values is Row-Major
186186
for x, y_row in zip(paired_data.ordinates, paired_data.values):
187-
full_row: list[float] = [round(x, ROUND_PRECISION)] + \
188-
[round(y, ROUND_PRECISION) for y in y_row]
187+
full_row: list[float] = [_round_or_none(x)] + \
188+
[_round_or_none(y) for y in y_row]
189189
writer.writerow([counter] + full_row)
190190
counter += 1
191191
return
@@ -277,6 +277,19 @@ def paired_data_read_csv(cls: type[PairedData], path: str) -> PairedData:
277277
)
278278

279279

280+
def _round_or_none(value: float | None) -> float | None:
281+
"""
282+
Rounds a numeric value to ROUND_PRECISION, passing None through unchanged.
283+
284+
Parameters:
285+
value (float | None): the value to round, or None if missing
286+
287+
Returns:
288+
float | None: the rounded value, or None if value was None
289+
"""
290+
return round(value, ROUND_PRECISION) if value is not None else None
291+
292+
280293
def _empty_path_parts() -> dict[str, str]:
281294
"""
282295
Returns a fresh dict of empty A-F DSS path components.

tests/test_pd_csv.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,28 @@ def test_curve_count_on_dataless_read_should_be_zero(self):
640640
pd = self.read_pd_from_string(content)
641641
self.assertEqual(pd.curve_count(), 0)
642642

643+
def test_to_csv_missing_value_does_not_crash(self):
644+
"""A blank/malformed numeric cell is read back as DEFAULT_MISSING_VALUE
645+
(None), producing an object-dtype array. The writer must tolerate a None
646+
ordinate/value (write an empty cell) rather than crash trying to round it."""
647+
pd = self.read_pd_from_string(
648+
"Type,TIME,POINTS\n"
649+
"1,,10\n" # blank ordinate -> None
650+
"2,2,20\n"
651+
)
652+
written = self.write_pd_to_string(pd, with_metadata=True)
653+
self.assertIn("1,,10.0", written)
654+
self.assertIn("2,2.0,20.0", written)
655+
656+
def test_round_trip_nan_value_survives(self):
657+
"""A NaN dependent value is written as 'nan' and read back as a real NaN
658+
(it is NOT collapsed into the missing-value default)."""
659+
path = self.test_files.create_test_file(".csv")
660+
pd = self.make_pd(x_values=[1.0], y_values=[[float("nan")]], labels=["a"])
661+
pd.to_csv(path, with_metadata=True)
662+
result = PairedData.read_csv(path)
663+
self.assertTrue(math.isnan(result.values.tolist()[0][0]))
664+
643665

644666
if __name__ == "__main__":
645667
unittest.main()

tests/test_ts_csv.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from datetime import datetime
33
from unittest.mock import mock_open, patch
44

5+
import numpy as np
6+
57
from file_manager import FileManager
68

79
from hecdss import HecDss
@@ -565,6 +567,101 @@ def test_read_write_with_missing(self):
565567
self.assertIn("Type,Date/Time,INST-VAL", written_data)
566568
self.assertIn("2,01Sep2021 1200,\r\n", written_data)
567569

570+
def test_to_csv_writes_empty_cell_for_missing_value(self):
571+
"""A missing (None) value is written as an empty cell, never the literal
572+
text 'None'. (Isolates the write path with an injected None, independent
573+
of what the reader produces.)"""
574+
rts = RegularTimeSeries.create(
575+
values=[1.0, 2.0],
576+
times=[datetime(2021, 9, 1, 6, 0), datetime(2021, 9, 1, 12, 0)],
577+
units="CFS",
578+
data_type="INST-VAL",
579+
path="/A/B/C//6Hour/F/",
580+
)
581+
rts.values = np.array([1.0, None], dtype=object) # simulate a missing value
582+
mock_file = mock_open()
583+
with patch("builtins.open", mock_file):
584+
rts.to_csv("fake.csv", with_metadata=False)
585+
handle = mock_file()
586+
written = "".join(call.args[0] for call in handle.write.call_args_list)
587+
self.assertIn("2,01Sep2021 1200,\r\n", written)
588+
self.assertNotIn("None", written)
589+
590+
def test_to_csv_quality_shorter_than_values_truncates(self):
591+
"""FOOTGUN: to_csv takes the with-quality branch whenever quality is
592+
non-empty, then zips (times, values, quality). A quality list shorter than
593+
values makes zip stop at the shortest input, so trailing data points are
594+
silently dropped from the CSV."""
595+
rts = RegularTimeSeries.create(
596+
values=[1.0, 2.0, 3.0],
597+
times=[
598+
datetime(2021, 9, 1, 6, 0),
599+
datetime(2021, 9, 1, 12, 0),
600+
datetime(2021, 9, 1, 18, 0),
601+
],
602+
quality=[0], # only one flag for three values
603+
units="CFS",
604+
data_type="INST-VAL",
605+
path="/A/B/C//6Hour/F/",
606+
)
607+
mock_file = mock_open()
608+
with patch("builtins.open", mock_file):
609+
rts.to_csv("fake.csv", with_metadata=False)
610+
handle = mock_file()
611+
written = "".join(call.args[0] for call in handle.write.call_args_list)
612+
self.assertIn("1,01Sep2021 0600,1.0,0", written)
613+
self.assertNotIn("2,01Sep2021 1200", written) # silently dropped
614+
self.assertNotIn("3,01Sep2021 1800", written) # silently dropped
615+
616+
def test_read_csv_skips_short_data_row(self):
617+
"""A data row with fewer than 3 columns is malformed and skipped without
618+
raising (parity with the paired-data reader's short-row handling)."""
619+
content = (
620+
"Type,Date/Time,INST-VAL\n"
621+
"1,01Sep2021 0600\n" # only 2 columns -> skipped
622+
"2,01Sep2021 1200,20.0\n"
623+
)
624+
rts = self.read_rts_from_string(content)
625+
self.assertEqual(rts.values.tolist(), [20.0])
626+
self.assertEqual(rts.times, [datetime(2021, 9, 1, 12, 0)])
627+
628+
def test_read_csv_metadata_only_no_data_rows(self):
629+
"""Metadata rows with zero data rows yield an empty series (no crash);
630+
units and the E interval are still captured."""
631+
content = (
632+
"A,,,A\n"
633+
"E,,,6Hour\n"
634+
"Units,,,CFS\n"
635+
"Type,Date/Time,INST-VAL\n"
636+
)
637+
rts = self.read_rts_from_string(content)
638+
self.assertEqual(rts.values.tolist(), [])
639+
self.assertEqual(rts.times, [])
640+
self.assertEqual(rts.units, "CFS")
641+
self.assertEqual(rts.interval, 21600)
642+
643+
def test_round_trip_irregular(self):
644+
"""Full write->read round trip for IrregularTimeSeries on a real temp file:
645+
irregular gaps, units, data_type and id all survive."""
646+
path = self.test_files.create_test_file(".csv")
647+
its = IrregularTimeSeries.create(
648+
values=[10.5, 20.0, 42.0],
649+
times=[datetime(2021, 9, 1), datetime(2021, 9, 5), datetime(2021, 9, 20)],
650+
units="CFS",
651+
data_type="INST-VAL",
652+
path="/A/B/C//IR-Year/F/",
653+
)
654+
its.to_csv(path, with_metadata=True)
655+
result = IrregularTimeSeries.read_csv(path)
656+
self.assertEqual(result.values.tolist(), [10.5, 20.0, 42.0])
657+
self.assertEqual(
658+
result.times,
659+
[datetime(2021, 9, 1), datetime(2021, 9, 5), datetime(2021, 9, 20)],
660+
)
661+
self.assertEqual(result.units, "CFS")
662+
self.assertEqual(result.data_type, "INST-VAL")
663+
self.assertEqual(result.id, "/A/B/C//IR-Year/F/")
664+
568665

569666
if __name__ == "__main__":
570667
unittest.main()

0 commit comments

Comments
 (0)