Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 19 additions & 17 deletions ladybug/_datacollectionbase.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,11 +222,11 @@ def is_in_data_type_range(self, raise_exception=True):
def to_mutable(self):
"""Get a mutable version of this collection."""

return self.mutable_class(self.header, self.values, self.datetimes)
return self.mutable_class(self.header.duplicate(), self.values, self.datetimes)

def to_immutable(self):
"""Get an immutable version of this collection."""
new_obj = self.immutable_class(self.header, self.values, self.datetimes)
new_obj = self.immutable_class(self.header.duplicate(), self.values, self.datetimes)
new_obj._validated_a_period = self._validated_a_period
return new_obj

Expand Down Expand Up @@ -408,9 +408,9 @@ def filter_by_conditional_statement(self, statement):
col_obj = self.mutable_class
try:
collection = col_obj(self.header.duplicate(), _filt_values, _filt_datetimes)
except AssertionError as e:
raise AssertionError('No value meets the conditional statement.'
'\n{}'.format(e))
except ValueError as e:
raise ValueError('No value meets the conditional statement.'
'\n{}'.format(e))
collection._validated_a_period = self._validated_a_period
return collection

Expand All @@ -435,8 +435,8 @@ def filter_by_range(self, greater_than=float('-inf'), less_than=float('inf')):
col_obj = self.mutable_class
try:
collection = col_obj(self.header.duplicate(), _filt_values, _filt_datetimes)
except AssertionError as e:
raise AssertionError(
except ValueError as e:
raise ValueError(
'No value was found in the range {} to {}.\n{}'.format(
greater_than, less_than, e))
collection._validated_a_period = self._validated_a_period
Expand Down Expand Up @@ -565,9 +565,9 @@ def filter_collections_by_statement(data_collections, statement):
data_collections, statement)
try:
collections = [coll.filter_by_pattern(pattern) for coll in data_collections]
except AssertionError as e:
raise AssertionError('No value meets the conditional statement.'
'\n{}'.format(e))
except ValueError as e:
raise ValueError('No value meets the conditional statement.'
'\n{}'.format(e))
return collections

@staticmethod
Expand Down Expand Up @@ -1057,13 +1057,15 @@ def _filter_by_pattern(self, pattern):

def _check_values(self, values):
"""Check values whenever they come through the values setter."""
assert isinstance(values, Iterable) and not \
isinstance(values, (str, dict, bytes, bytearray)), \
'values should be a list or tuple. Got {}'.format(type(values))
assert len(values) == len(self.datetimes), \
'Length of values list must match length of datetimes list. ' \
'{} != {}'.format(len(values), len(self.datetimes))
assert len(values) > 0, 'Data Collection must include at least one value'
if not isinstance(values, Iterable) or isinstance(
values, (str, dict, bytes, bytearray)):
raise TypeError('values should be a list or tuple. Got {}'.format(type(values)))
if len(values) != len(self.datetimes):
raise ValueError(
'Length of values list must match length of datetimes list. '
'{} != {}'.format(len(values), len(self.datetimes)))
if len(values) == 0:
raise ValueError('Data Collection must include at least one value')

def _check_aligned_header(self, data_type, unit):
"""Check the header inputs whenever get_aligned_collection is called."""
Expand Down
18 changes: 10 additions & 8 deletions ladybug/datacollection.py
Original file line number Diff line number Diff line change
Expand Up @@ -914,11 +914,11 @@ def group_by_month(self):

def to_mutable(self):
"""Get an mutable version of this collection."""
return self.mutable_class(self.header, self.values)
return self.mutable_class(self.header.duplicate(), self.values)

def to_immutable(self):
"""Get an immutable version of this collection."""
return self.immutable_class(self.header, self.values)
return self.immutable_class(self.header.duplicate(), self.values)

def get_aligned_collection(self, value=0, data_type=None, unit=None, mutable=None):
"""Return a Collection aligned with this one composed of one repeated value.
Expand Down Expand Up @@ -1029,12 +1029,14 @@ def _get_analysis_period_subset(self, a_per):

def _check_values(self, values):
"""Check values whenever they come through the values setter."""
assert isinstance(values, Iterable) and not isinstance(
values, (str, dict, bytes, bytearray)), \
'values should be a list or tuple. Got {}'.format(type(values))
assert len(values) == len(self.header.analysis_period), 'Length of ' \
'values does not match that expected by the header analysis_period.'\
' {} != {}'.format(len(values), len(self.header.analysis_period))
if not isinstance(values, Iterable) or isinstance(
values, (str, dict, bytes, bytearray)):
raise TypeError('values should be a list or tuple. Got {}'.format(type(values)))
if len(values) != len(self.header.analysis_period):
raise ValueError(
'Length of values does not match that expected by the header '
'analysis_period. {} != {}'.format(
len(values), len(self.header.analysis_period)))

@property
def is_continuous(self):
Expand Down
43 changes: 37 additions & 6 deletions ladybug/datacollectionimmutable.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,33 @@ def _mutable_message(self):
return 'values are immutable for {}.\nUse to_mutable() method to get a ' \
'mutable version of this collection.'.format(self.__class__.__name__)

def convert_to_unit(self, unit):
"""Not available for immutable collections; use to_unit() instead."""
raise AttributeError(self._mutable_message)

def convert_to_ip(self):
"""Not available for immutable collections; use to_ip() instead."""
raise AttributeError(self._mutable_message)

def convert_to_si(self):
"""Not available for immutable collections; use to_si() instead."""
raise AttributeError(self._mutable_message)

def to_unit(self, unit):
new_data_c = self.to_mutable()
new_data_c.convert_to_unit(unit)
return new_data_c

def to_ip(self):
new_data_c = self.to_mutable()
new_data_c.convert_to_ip()
return new_data_c

def to_si(self):
new_data_c = self.to_mutable()
new_data_c.convert_to_si()
return new_data_c

def to_immutable(self):
"""Get an immutable version of this collection."""
return self.duplicate()
Expand Down Expand Up @@ -76,7 +103,8 @@ def convert_to_culled_timestep(self, timestep=1):

def to_mutable(self):
"""Get a mutable version of this collection."""
new_obj = HourlyDiscontinuousCollection(self.header, self.values, self.datetimes)
new_obj = HourlyDiscontinuousCollection(
self.header.duplicate(), self.values, self.datetimes)
new_obj._validated_a_period = self._validated_a_period
return new_obj

Expand All @@ -89,15 +117,15 @@ class HourlyContinuousCollectionImmutable(
@property
def mutable_class(self):
"""Get the immutable class for this type of data collection."""
return DailyCollection
return HourlyContinuousCollection

def convert_to_culled_timestep(self, timestep=1):
"""This method is not available for immutable collections."""
raise AttributeError(self._mutable_message)

def to_mutable(self):
"""Get a mutable version of this collection."""
new_obj = HourlyContinuousCollection(self.header, self.values)
new_obj = HourlyContinuousCollection(self.header.duplicate(), self.values)
new_obj._validated_a_period = self._validated_a_period
return new_obj

Expand All @@ -118,7 +146,8 @@ def mutable_class(self):

def to_mutable(self):
"""Get a mutable version of this collection."""
new_obj = DailyCollection(self.header, self.values, self.datetimes)
new_obj = DailyCollection(
self.header.duplicate(), self.values, self.datetimes)
new_obj._validated_a_period = self._validated_a_period
return new_obj

Expand All @@ -135,7 +164,8 @@ def mutable_class(self):

def to_mutable(self):
"""Get a mutable version of this collection."""
new_obj = MonthlyCollection(self.header, self.values, self.datetimes)
new_obj = MonthlyCollection(
self.header.duplicate(), self.values, self.datetimes)
new_obj._validated_a_period = self._validated_a_period
return new_obj

Expand All @@ -152,6 +182,7 @@ def mutable_class(self):

def to_mutable(self):
"""Get a mutable version of this collection."""
new_obj = MonthlyPerHourCollection(self.header, self.values, self.datetimes)
new_obj = MonthlyPerHourCollection(
self.header.duplicate(), self.values, self.datetimes)
new_obj._validated_a_period = self._validated_a_period
return new_obj
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: IronPython",
"Operating System :: OS Independent"
Expand Down
36 changes: 36 additions & 0 deletions tests/datacollection_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,26 @@ def test_init_incorrect():
[v1, v2], {'1': 1, '2': 2})


def test_values_validation_uses_explicit_exceptions():
"""Validate collection values even when Python assertions are disabled."""
a_per = AnalysisPeriod(6, 21, 12, 6, 21, 13)
header = Header(Temperature(), 'C', a_per)
datetimes = [DateTime(6, 21, 12), DateTime(6, 21, 13)]

with pytest.raises(TypeError, match='values should be'):
BaseCollection(header, '20,25', datetimes)
with pytest.raises(ValueError, match='Length of values list'):
BaseCollection(header, [20], datetimes)
with pytest.raises(ValueError, match='at least one value'):
BaseCollection(header, [], [])

annual_header = Header(Temperature(), 'C', AnalysisPeriod())
with pytest.raises(TypeError, match='values should be'):
HourlyContinuousCollection(annual_header, '20')
with pytest.raises(ValueError, match='header analysis_period'):
HourlyContinuousCollection(annual_header, [20])


def test_init_hourly():
"""Test the init methods for discontinuous collections."""
a_per = AnalysisPeriod(6, 21, 12, 6, 21, 13)
Expand Down Expand Up @@ -1336,6 +1356,22 @@ def test_filter_collections_by_statement():
assert isinstance(filt_coll[0], HourlyDiscontinuousCollection)


def test_filter_no_match_error_message():
"""A filter that matches nothing reports why instead of a generic error."""
a_per = AnalysisPeriod(6, 21, 12, 6, 21, 13)
dts = [DateTime(6, 21, 12), DateTime(6, 21, 13)]
dc = HourlyDiscontinuousCollection(Header(Temperature(), 'C', a_per), [20, 25], dts)
with pytest.raises(ValueError, match='No value meets the conditional'):
dc.filter_by_conditional_statement('a > 100')
with pytest.raises(ValueError, match='No value was found in the range'):
dc.filter_by_range(100, 200)

header = Header(Temperature(), 'C', AnalysisPeriod(end_month=1, end_day=1))
cont = HourlyContinuousCollection(header, list(xrange(24)))
with pytest.raises(ValueError, match='No value meets the conditional'):
HourlyContinuousCollection.filter_collections_by_statement([cont], 'a > 100')


def test_is_in_range_data_type():
"""Test the function to check whether values are in range for the data_type."""
header1 = Header(Temperature(), 'C', AnalysisPeriod())
Expand Down
91 changes: 91 additions & 0 deletions tests/datacollectionimmutable_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,97 @@ def test_init_continuous():
assert not dc4.is_mutable


def _make_collection(kind):
"""Get a mutable Celsius temperature collection of the requested kind."""
a_per = AnalysisPeriod(6, 21, 12, 6, 21, 13)
day_per = AnalysisPeriod(6, 21, 0, 6, 22, 23)
mon_per = AnalysisPeriod(6, 1, 0, 7, 31, 23)
builders = {
'discontinuous': lambda: HourlyDiscontinuousCollection(
Header(Temperature(), 'C', a_per), [20, 25],
[DateTime(6, 21, 12), DateTime(6, 21, 13)]),
'continuous': lambda: HourlyContinuousCollection(
Header(Temperature(), 'C', AnalysisPeriod(end_month=1, end_day=1)),
[20] * 24),
'daily': lambda: DailyCollection(
Header(Temperature(), 'C', day_per), [20, 25], day_per.doys_int),
'monthly': lambda: MonthlyCollection(
Header(Temperature(), 'C', mon_per), [20, 25], mon_per.months_int),
'monthly_per_hour': lambda: MonthlyPerHourCollection(
Header(Temperature(), 'C', mon_per), [20] * 24 + [25] * 24,
mon_per.months_per_hour)
}
return builders[kind]()


COLLECTION_KINDS = \
('discontinuous', 'continuous', 'daily', 'monthly', 'monthly_per_hour')


@pytest.mark.parametrize('kind', COLLECTION_KINDS)
def test_mutable_immutable_conversion_does_not_share_header(kind):
"""Converting between mutable and immutable must give each its own header."""
source = _make_collection(kind)
immutable = source.to_immutable()
assert immutable.header is not source.header
assert immutable.to_mutable().header is not immutable.header
assert source.to_mutable().header is not source.header


@pytest.mark.parametrize('kind', COLLECTION_KINDS)
def test_unit_conversion_on_immutable_collection(kind):
"""In-place conversion is blocked; to_* returns a converted mutable copy."""
source = _make_collection(kind)
immutable = source.to_immutable()

with pytest.raises(AttributeError):
immutable.convert_to_ip()
with pytest.raises(AttributeError):
immutable.convert_to_si()
with pytest.raises(AttributeError):
immutable.convert_to_unit('F')

converted = immutable.to_ip()
assert type(converted) is type(source)
assert converted.is_mutable
assert converted.header.unit == 'F'
assert converted.values[0] == pytest.approx(68)
assert immutable.to_si().is_mutable
assert immutable.to_unit('F').header.unit == 'F'
assert immutable.header.unit == 'C'
assert immutable.values[0] == 20
assert source.header.unit == 'C'
assert source.values[0] == 20

source.convert_to_ip()
assert immutable.header.unit == 'C'


@pytest.mark.parametrize('kind', COLLECTION_KINDS)
def test_mutable_and_immutable_classes_point_at_each_other(kind):
"""Each mutable class and its immutable counterpart must name one another."""
source = _make_collection(kind)
immutable = source.to_immutable()
assert source.mutable_class is type(source)
assert source.immutable_class is type(immutable)
assert immutable.mutable_class is type(source)
assert immutable.immutable_class is type(immutable)


@pytest.mark.parametrize('kind', COLLECTION_KINDS)
def test_get_aligned_collection_mutability(kind):
"""get_aligned_collection honors mutable= from a mutable or immutable start."""
source = _make_collection(kind)
immutable = source.to_immutable()
for start in (source, immutable):
assert type(start.get_aligned_collection(5)) is type(start)
assert type(start.get_aligned_collection(5, mutable=False)) is type(immutable)
aligned = start.get_aligned_collection(5, mutable=True)
assert type(aligned) is type(source)
assert aligned.is_mutable
assert set(aligned.values) == {5}


def test_daily():
"""Test the daily collections."""
a_per = AnalysisPeriod(6, 21, 0, 6, 22, 23)
Expand Down