diff --git a/CITATION.cff b/CITATION.cff index fb29d12b..dedaec0b 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -39,5 +39,5 @@ abstract: >- ANIMATE) license: BSD-2-Clause commit: a8b74df506f141d7840403a06a255959157bcc1e -version: 0.1.0 -date-released: '2023-03-31' \ No newline at end of file +version: 0.3.0 +date-released: '2023-09-30' \ No newline at end of file diff --git a/README.md b/README.md index 2511c15e..e6dbaa19 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ See the Publications section for more information and example of uses of the fra - Demos are located in `demo/` - Visit [API documentation page](https://pnnl.github.io/ConStrain/) to learn about how to use the ConStrain API. - Visit [Guideline 36 Verification Items List](./design/g36_lib_contents.md) to learn more about the ASHRAE Guideline 36 related verification in ConStrain verification library. +- Visit [Local Loop Verification Items List](./design/local_loop_verification_items_list.md) to learn more about local loop performance verification library. +- Visit [Brick Integration Doc](./design/brick_integration_doc.md) to learn more about the beta version of brick schema integration API. diff --git a/design/local_loop_lib_contents.md b/design/local_loop_lib_contents.md new file mode 100644 index 00000000..ad65e7db --- /dev/null +++ b/design/local_loop_lib_contents.md @@ -0,0 +1,120 @@ +# Local Loop Performance Verification Library Items + +---- + +## Local Loop Performance Verification - Set Point Tracking + +### Description + +This verification checks the set point tracking ability of local control loops. + +### Verification logic + +With a threshold of 5% of abs(set_point) (if the set point is 0, then the threshold is default to be 0.01), if the number of samples of which the error is larger than this threshold is beyond 5% of number of all samples, then this verification fails; Otherwise, it passes. + +### Data requirements + +- feedback_sensor: feedback sensor reading of the subject to be controlled towards a set point +- set_point: set point value + + +## Local Loop Performance Verification - Set Point Unmet Hours + +### Description + +This verification checks the set point tracking ability of local control loops. + +### Verification logic + +Instead of checking the number of samples among the whole data set for which the set points are not met, this verification checks the total accumulated time that the set points are not met within a threshold of 5% of abs(set_point) (if the set point is 0, then the threshold is default to be 0.01). + +If the accumulated time of unmet set point is beyond 5% of the whole duration the data covers, then this verification fails; otherwise, it passes. + +### Data requirements + +- feedback_sensor: feedback sensor reading of the subject to be controlled towards a set point +- set_point: set point value + +## Local Loop Performance Verification - Direct Acting Loop Actuator Maximum Saturation + +### Description + +This verification checks that a direct acting control loop would saturate its actuator to maximum when the error is consistently above the set point [^1]. + +### Verification logic + +If the sensed data values are consistently above its set point, and after a default of 1 hour, the control command is still not saturated to maximum, then the verification fails; Otherwise, it passes. + +### Data requirements + +- feedback_sensor: feedback sensor reading of the subject to be controlled towards a set point +- set_point: set point value +- cmd : control command +- cmd_max: control command range maximum value + +## Local Loop Performance Verification - Direct Acting Loop Actuator Minimum Saturation + +### Description + +This verification checks that a direct acting control loop would saturate its actuator to minimum when the error is consistently below the set point [^1]. + +### Verification logic + +If the sensed data values are consistently below its set point, and after a default of 1 hour, the control command is still not saturated to minimum, then the verification fails; Otherwise, it passes. + +### Data requirements + +- feedback_sensor: feedback sensor reading of the subject to be controlled towards a set point +- set_point: set point value +- cmd : control command +- cmd_min: control command range minimum value + +## Local Loop Performance Verification - Reverse Acting Loop Actuator Maximum Saturation + +### Description + +This verification checks that a reverse acting control loop would saturate its actuator to maximum when the error is consistently below the set point [^1]. + +### Verification logic + +If the sensed data values are consistently below its set point, and after a default of 1 hour, the control command is still not saturated to maximum, then the verification fails; Otherwise, it passes. + +### Data requirements + +- feedback_sensor: feedback sensor reading of the subject to be controlled towards a set point +- set_point: set point value +- cmd : control command +- cmd_max: control command range maximum value + +## Local Loop Performance Verification - Reverse Acting Loop Actuator Minimum Saturation + +### Description + +This verification checks that a reverse acting control loop would saturate its actuator to minimum when the error is consistently above the set point [^1]. + +### Verification logic + +If the sensed data values are consistently above its set point, and after a default of 1 hour, the control command is still not saturated to minimum, then the verification fails; Otherwise, it passes. + +### Data requirements + +- feedback_sensor: feedback sensor reading of the subject to be controlled towards a set point +- set_point: set point value +- cmd : control command +- cmd_min: control command range minimum value + + + +## Local Loop Performance Verification - Actuator Rate of Change + +### Description + +This verification checks if a local loop actuator has dramatic change of its actuating command. This verification is implemented as instructed by ASHRAE Guideline 36 2021 Section 5.1.9 in Verification Item `G36OutputChangeRateLimit`. + + + +[^1]: Lei, Xuechen, Yan Chen, Mario Bergés, and Burcu Akinci. "Formalized control logic fault definition with ontological reasoning for air handling units." Automation in Construction 129 (2021): 103781. \ No newline at end of file diff --git a/schema/library.json b/schema/library.json index 5d3942fb..f1c8adc7 100644 --- a/schema/library.json +++ b/schema/library.json @@ -1055,5 +1055,91 @@ ], "description_verification_type": "rule-based", "assertions_type": "pass" + }, + "LocalLoopSetPointTracking": { + "library_item_id": 47, + "description_brief": "Local Loop Performance Verification - Set Point Tracking", + "description_datapoints": { + "feedback_sensor": "feedback sensor reading of the subject to be controlled towards a set point", + "set_point": "set point value" + }, + "description_assertions": [ + "With a threshold of 5% of abs(set_point) (if the set point is 0, then the threshold is default to be 0.01), if the number of samples of which the error is larger than this threshold is beyond 5% of number of all samples, then this verification fails; Otherwise, it passes." + ], + "description_verification_type": "procedure-based", + "assertions_type": "pass" + }, + "LocalLoopUnmetHours": { + "library_item_id": 48, + "description_brief": "Local Loop Performance Verification - Set Point Unmet Hours", + "description_datapoints": { + "feedback_sensor": "feedback sensor reading of the subject to be controlled towards a set point", + "set_point": "set point value" + }, + "description_assertions": [ + "Instead of checking the number of samples among the whole data set for which the set points are not met, this verification checks the total accumulated time that the set points are not met within a threshold of 5% of abs(set_point) (if the set point is 0, then the threshold is default to be 0.01)." + ], + "description_verification_type": "procedure-based", + "assertions_type": "pass" + }, + "LocalLoopSaturationDirectActingMax": { + "library_item_id": 49, + "description_brief": "Local Loop Performance Verification - Direct Acting Loop Actuator Maximum Saturation", + "description_datapoints": { + "feedback_sensor": "feedback sensor reading of the subject to be controlled towards a set point", + "set_point": "set point value", + "cmd": "control command", + "cmd_max": "control command range maximum value" + }, + "description_assertions": [ + "If the sensed data values are consistently above its set point, and after a default of 1 hour, the control command is still not saturated to maximum, then the verification fails; Otherwise, it passes." + ], + "description_verification_type": "procedure-based", + "assertions_type": "pass" + }, + "LocalLoopSaturationDirectActingMin": { + "library_item_id": 50, + "description_brief": "Local Loop Performance Verification - Direct Acting Loop Actuator Minimum Saturation", + "description_datapoints": { + "feedback_sensor": "feedback sensor reading of the subject to be controlled towards a set point", + "set_point": "set point value", + "cmd": "control command", + "cmd_min": "control command range minimum value" + }, + "description_assertions": [ + "If the sensed data values are consistently below its set point, and after a default of 1 hour, the control command is still not saturated to minimum, then the verification fails; Otherwise, it passes." + ], + "description_verification_type": "procedure-based", + "assertions_type": "pass" + }, + "LocalLoopSaturationReverseActingMax": { + "library_item_id": 51, + "description_brief": "Local Loop Performance Verification - Reverse Acting Loop Actuator Maximum Saturation", + "description_datapoints": { + "feedback_sensor": "feedback sensor reading of the subject to be controlled towards a set point", + "set_point": "set point value", + "cmd": "control command", + "cmd_max": "control command range maximum value" + }, + "description_assertions": [ + "If the sensed data values are consistently below its set point, and after a default of 1 hour, the control command is still not saturated to maximum, then the verification fails; Otherwise, it passes." + ], + "description_verification_type": "procedure-based", + "assertions_type": "pass" + }, + "LocalLoopSaturationReverseActingMin": { + "library_item_id": 52, + "description_brief": "Local Loop Performance Verification - Reverse Acting Loop Actuator Minimum Saturation", + "description_datapoints": { + "feedback_sensor": "feedback sensor reading of the subject to be controlled towards a set point", + "set_point": "set point value", + "cmd": "control command", + "cmd_min": "control command range minimum value" + }, + "description_assertions": [ + "If the sensed data values are consistently above its set point, and after a default of 1 hour, the control command is still not saturated to minimum, then the verification fails; Otherwise, it passes." + ], + "description_verification_type": "procedure-based", + "assertions_type": "pass" } } \ No newline at end of file diff --git a/src/library/LocalLoopSaturationDirectActingMax.py b/src/library/LocalLoopSaturationDirectActingMax.py new file mode 100644 index 00000000..04e408ef --- /dev/null +++ b/src/library/LocalLoopSaturationDirectActingMax.py @@ -0,0 +1,63 @@ +""" +## Local Loop Performance Verification - Direct Acting Loop Actuator Maximum Saturation + +### Description + +This verification checks that a direct acting control loop would saturate its actuator to maximum when the error is consistently above the set point. + +### Verification logic + +If the sensed data values are consistently above its set point, and after a default of 1 hour, the control command is still not saturated to maximum, then the verification fails; Otherwise, it passes. + +### Data requirements + +- feedback_sensor: feedback sensor reading of the subject to be controlled towards a set point +- set_point: set point value +- cmd: control command +- cmd_max: control command range maximum value + +""" + +import pandas as pd +from checklib import RuleCheckBase + + +class LocalLoopSaturationDirectActingMax(RuleCheckBase): + points = ["feedback_sensor", "set_point", "cmd", "cmd_max"] + + def saturation_flag(self, t): + if 0 <= t["cmd_max"] - t["cmd"] <= 0.01: + return True + else: + return False + + def err_flag(self, t): + if t["feedback_sensor"] > t["set_point"]: + return True + else: + return False + + def verify(self): + self.saturation = self.df.apply(lambda t: self.saturation_flag(t), axis=1) + self.err = self.df.apply(lambda t: self.err_flag(t), axis=1) + self.result = pd.Series(index=self.df.index) + err_start_time = None + err_time = 0 + for cur_time, cur in self.df.iterrows(): + if self.err.loc[cur_time]: + if err_start_time is None: + err_start_time = cur_time + else: + err_time = ( + cur_time - err_start_time + ).total_seconds() / 3600 # in hours + else: # reset + err_start_time = None + err_time = 0 + + if err_time > 1 and (not self.saturation.loc[cur_time]): + result_flag = False + else: + result_flag = True + + self.result.loc[cur_time] = result_flag diff --git a/src/library/LocalLoopSaturationDirectActingMin.py b/src/library/LocalLoopSaturationDirectActingMin.py new file mode 100644 index 00000000..86a21d62 --- /dev/null +++ b/src/library/LocalLoopSaturationDirectActingMin.py @@ -0,0 +1,63 @@ +""" +## Local Loop Performance Verification - Direct Acting Loop Actuator Minimum Saturation + +### Description + +This verification checks that a direct acting control loop would saturate its actuator to minimum when the error is consistently below the set point. + +### Verification logic + +If the sensed data values are consistently below its set point, and after a default of 1 hour, the control command is still not saturated to minimum, then the verification fails; Otherwise, it passes. + +### Data requirements + +- feedback_sensor: feedback sensor reading of the subject to be controlled towards a set point +- set_point: set point value +- cmd: control command +- cmd_min: control command range minimum value + +""" + +import pandas as pd +from checklib import RuleCheckBase + + +class LocalLoopSaturationDirectActingMin(RuleCheckBase): + points = ["feedback_sensor", "set_point", "cmd", "cmd_min"] + + def saturation_flag(self, t): + if 0 <= t["cmd"] - t["cmd_min"] <= 0.01: + return True + else: + return False + + def err_flag(self, t): + if t["feedback_sensor"] < t["set_point"]: + return True + else: + return False + + def verify(self): + self.saturation = self.df.apply(lambda t: self.saturation_flag(t), axis=1) + self.err = self.df.apply(lambda t: self.err_flag(t), axis=1) + self.result = pd.Series(index=self.df.index) + err_start_time = None + err_time = 0 + for cur_time, cur in self.df.iterrows(): + if self.err.loc[cur_time]: + if err_start_time is None: + err_start_time = cur_time + else: + err_time = ( + cur_time - err_start_time + ).total_seconds() / 3600 # in hours + else: # reset + err_start_time = None + err_time = 0 + + if err_time > 1 and (not self.saturation.loc[cur_time]): + result_flag = False + else: + result_flag = True + + self.result.loc[cur_time] = result_flag diff --git a/src/library/LocalLoopSaturationReverseActingMax.py b/src/library/LocalLoopSaturationReverseActingMax.py new file mode 100644 index 00000000..64018c0c --- /dev/null +++ b/src/library/LocalLoopSaturationReverseActingMax.py @@ -0,0 +1,63 @@ +""" +## Local Loop Performance Verification - Reverse Acting Loop Actuator Maximum Saturation + +### Description + +This verification checks that a reverse acting control loop would saturate its actuator to maximum when the error is consistently below the set point. + +### Verification logic + +If the sensed data values are consistently below its set point, and after a default of 1 hour, the control command is still not saturated to maximum, then the verification fails; Otherwise, it passes. + +### Data requirements + +- feedback_sensor: feedback sensor reading of the subject to be controlled towards a set point +- set_point: set point value +- cmd: control command +- cmd_max: control command range maximum value + +""" + +import pandas as pd +from checklib import RuleCheckBase + + +class LocalLoopSaturationReverseActingMax(RuleCheckBase): + points = ["feedback_sensor", "set_point", "cmd", "cmd_max"] + + def saturation_flag(self, t): + if 0 <= t["cmd_max"] - t["cmd"] <= 0.01: + return True + else: + return False + + def err_flag(self, t): + if t["feedback_sensor"] < t["set_point"]: + return True + else: + return False + + def verify(self): + self.saturation = self.df.apply(lambda t: self.saturation_flag(t), axis=1) + self.err = self.df.apply(lambda t: self.err_flag(t), axis=1) + self.result = pd.Series(index=self.df.index) + err_start_time = None + err_time = 0 + for cur_time, cur in self.df.iterrows(): + if self.err.loc[cur_time]: + if err_start_time is None: + err_start_time = cur_time + else: + err_time = ( + cur_time - err_start_time + ).total_seconds() / 3600 # in hours + else: # reset + err_start_time = None + err_time = 0 + + if err_time > 1 and (not self.saturation.loc[cur_time]): + result_flag = False + else: + result_flag = True + + self.result.loc[cur_time] = result_flag diff --git a/src/library/LocalLoopSaturationReverseActingMin.py b/src/library/LocalLoopSaturationReverseActingMin.py new file mode 100644 index 00000000..b9fc3de0 --- /dev/null +++ b/src/library/LocalLoopSaturationReverseActingMin.py @@ -0,0 +1,63 @@ +""" +## Local Loop Performance Verification - Reverse Acting Loop Actuator Minimum Saturation + +### Description + +This verification checks that a reverse acting control loop would saturate its actuator to minimum when the error is consistently above the set point. + +### Verification logic + +If the sensed data values are consistently above its set point, and after a default of 1 hour, the control command is still not saturated to minimum, then the verification fails; Otherwise, it passes. + +### Data requirements + +- feedback_sensor: feedback sensor reading of the subject to be controlled towards a set point +- set_point: set point value +- cmd: control command +- cmd_min: control command range minimum value + +""" + +import pandas as pd +from checklib import RuleCheckBase + + +class LocalLoopSaturationReverseActingMin(RuleCheckBase): + points = ["feedback_sensor", "set_point", "cmd", "cmd_min"] + + def saturation_flag(self, t): + if 0 <= t["cmd"] - t["cmd_min"] <= 0.01: + return True + else: + return False + + def err_flag(self, t): + if t["feedback_sensor"] > t["set_point"]: + return True + else: + return False + + def verify(self): + self.saturation = self.df.apply(lambda t: self.saturation_flag(t), axis=1) + self.err = self.df.apply(lambda t: self.err_flag(t), axis=1) + self.result = pd.Series(index=self.df.index) + err_start_time = None + err_time = 0 + for cur_time, cur in self.df.iterrows(): + if self.err.loc[cur_time]: + if err_start_time is None: + err_start_time = cur_time + else: + err_time = ( + cur_time - err_start_time + ).total_seconds() / 3600 # in hours + else: # reset + err_start_time = None + err_time = 0 + + if err_time > 1 and (not self.saturation.loc[cur_time]): + result_flag = False + else: + result_flag = True + + self.result.loc[cur_time] = result_flag diff --git a/src/library/LocalLoopSetPointTracking.py b/src/library/LocalLoopSetPointTracking.py new file mode 100644 index 00000000..7b1063b5 --- /dev/null +++ b/src/library/LocalLoopSetPointTracking.py @@ -0,0 +1,46 @@ +""" +## Local Loop Performance Verification - Set Point Tracking + +### Description + +This verification checks the set point tracking ability of local control loops. + +### Verification logic + +With a threshold of 5% of abs(set_point) (if the set point is 0, then the threshold is default to be 0.01), if the number of samples of which the error is larger than this threshold is beyond 5% of number of all samples, then this verification fails; Otherwise, it passes. + +### Data requirements + +- feedback_sensor: feedback sensor reading of the subject to be controlled towards a set point +- set_point: set point value + +""" + +from checklib import RuleCheckBase + + +class LocalLoopSetPointTracking(RuleCheckBase): + points = ["feedback_sensor", "set_point"] + + def error_below_5percent(self, t): + # this method checks each sample, and returns true if the error is within 5 percent of absolute setpoint value + # if the set point is 0, a default error threshold of 0.01 is used + err_abs = abs(t["feedback_sensor"] - t["set_point"]) + if t["set_point"] == 0: + if err_abs > 0.01: + return False + else: + return True + if err_abs / abs(t["set_point"]) > 0.05: + return False + else: + return True + + def verify(self): + self.result = self.df.apply(lambda t: self.error_below_5percent(t), axis=1) + + def check_bool(self): + if len(self.result[self.result == False]) / len(self.result) > 0.05: + return False + else: + return True diff --git a/src/library/LocalLoopUnmetHours.py b/src/library/LocalLoopUnmetHours.py new file mode 100644 index 00000000..8425b6fa --- /dev/null +++ b/src/library/LocalLoopUnmetHours.py @@ -0,0 +1,95 @@ +""" +## Local Loop Performance Verification - Set Point Unmet Hours + +### Description + +This verification checks the set point tracking ability of local control loops. + +### Verification logic + +Instead of checking the number of samples among the whole data set for which the set points are not met, this verification checks the total accumulated time that the set points are not met within a threshold of 5% of abs(set_point) (if the set point is 0, then the threshold is default to be 0.01). + +If the accumulated time of unmet set point is beyond 5% of the whole duration the data covers, then this verification fails; otherwise, it passes. + +### Data requirements + +- feedback_sensor: feedback sensor reading of the subject to be controlled towards a set point +- set_point: set point value + +""" + +import pandas as pd +from checklib import RuleCheckBase + + +class LocalLoopUnmetHours(RuleCheckBase): + points = ["feedback_sensor", "set_point"] + + def error_below_5percent(self, t): + # this method checks each sample, and returns true if the error is within 5 percent of absolute setpoint value + # if the set point is 0, a default error threshold of 0.01 is used + err_abs = abs(t["feedback_sensor"] - t["set_point"]) + if t["set_point"] == 0: + if err_abs > 0.01: + return False + else: + return True + if err_abs / abs(t["set_point"]) > 0.05: + return False + else: + return True + + def time_error_below_5percent(self, cur, prev, cur_time, prev_time): + if prev is None: + return 0 + + if (not self.error_below_5percent(cur)) and ( + not self.error_below_5percent(prev) + ): + time_delta = cur_time - prev_time + hour_change = time_delta.total_seconds() / 3600 + return hour_change + else: + return 0 + + def verify(self): + self.result = self.df.apply(lambda t: self.error_below_5percent(t), axis=1) + self.unmethours_ts = pd.Series(index=self.df.index) + prev_time = None + prev = None + first_flag = True + for cur_time, cur in self.df.iterrows(): + if first_flag: + self.unmethours_ts.loc[cur_time] = 0 + first_flag = False + else: + self.unmethours_ts.loc[cur_time] = self.time_error_below_5percent( + cur, prev, cur_time, prev_time + ) + prev_time = cur_time + prev = cur + + self.total_unmet_hours = sum(self.unmethours_ts) + self.total_hours = ( + self.unmethours_ts.index[-1] - self.unmethours_ts.index[0] + ).total_seconds() / 3600 + + def check_bool(self): + if self.total_unmet_hours / self.total_hours > 0.05: + return False + else: + return True + + def check_detail(self): + print("Verification results dict: ") + output = { + "Sample #": len(self.result), + "Pass #": len(self.result[self.result == True]), + "Fail #": len(self.result[self.result == False]), + "Verification Passed?": self.check_bool(), + "Total Data Duration Hours": self.total_hours, + "Total Unmet Hours": self.total_unmet_hours, + "Total Unmet Hours Ratio": self.total_unmet_hours / self.total_hours, + } + print(output) + return output diff --git a/src/library/__init__.py b/src/library/__init__.py index 1a2bb0d3..ff8ba6df 100644 --- a/src/library/__init__.py +++ b/src/library/__init__.py @@ -30,6 +30,12 @@ from .G36FreezeProtectionStage1 import * from .G36FreezeProtectionStage2 import * from .G36FreezeProtectionStage3 import * +from .LocalLoopSetPointTracking import * +from .LocalLoopUnmetHours import * +from .LocalLoopSaturationDirectActingMax import * +from .LocalLoopSaturationDirectActingMin import * +from .LocalLoopSaturationReverseActingMax import * +from .LocalLoopSaturationReverseActingMin import * __all__ = [ "AutomaticOADamperControl", @@ -67,4 +73,11 @@ "G36FreezeProtectionStage1", "G36FreezeProtectionStage2", "G36FreezeProtectionStage3", + "LocalLoopSetPointTracking", + "LocalLoopUnmetHours", + "LocalLoopSaturationDirectActingMax", + "LocalLoopSaturationDirectActingMin", + "LocalLoopSaturationReverseActingMax", + "LocalLoopSaturationReverseActingMin", + # "LocalLoopHuntingActivation", ] diff --git a/tests/test_localloop_SaturationDirectActingMax.py b/tests/test_localloop_SaturationDirectActingMax.py new file mode 100644 index 00000000..99edf354 --- /dev/null +++ b/tests/test_localloop_SaturationDirectActingMax.py @@ -0,0 +1,129 @@ +import unittest, sys +import datetime + +sys.path.append("./src") +from lib_unit_test_runner import * +from library import * +import pandas as pd + + +class TestLocalLoopSaturationDirectActingMax(unittest.TestCase): + def test_saturation_damax_pass(self): + points = ["feedback_sensor", "set_point", "cmd", "cmd_max"] + timestamp = [ + datetime(2023, 5, 1, 0, 5, 0), + datetime(2023, 5, 1, 1, 5, 10), + datetime(2023, 5, 1, 2, 5, 0), + datetime(2023, 5, 1, 3, 5, 30), + datetime(2023, 5, 1, 4, 5, 0), + datetime(2023, 5, 1, 5, 5, 0), + datetime(2023, 5, 1, 6, 5, 0), + datetime(2023, 5, 1, 7, 5, 0), + datetime(2023, 5, 1, 8, 5, 5), + datetime(2023, 5, 1, 9, 5, 0), + datetime(2023, 5, 1, 10, 5, 0), + ] + + data = [ + [100, 100, 50, 100], + [106, 100, 99.99, 100], + [107, 100, 100, 100], + [103, 100, 100, 100], + [102, 100, 100, 100], + [103, 100, 100, 100], + [103, 100, 100, 100], + [99, 100, 98, 100], + [100, 100, 55, 100], + [100, 100, 30, 100], + [100, 100, 100, 100], + ] + + df = pd.DataFrame(data, columns=points, index=timestamp) + expected_results = pd.Series( + [ + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + ] + ) + + verification_obj = run_test_verification_with_data( + "LocalLoopSaturationDirectActingMax", df + ) + + results = pd.Series(list(verification_obj.result)) + binaryflag = verification_obj.check_bool() + verification_obj.check_detail() + + self.assertTrue(results.equals(expected_results)) + self.assertTrue(binaryflag) + + def test_saturation_damax_pass(self): + points = ["feedback_sensor", "set_point", "cmd", "cmd_max"] + timestamp = [ + datetime(2023, 5, 1, 0, 5, 0), + datetime(2023, 5, 1, 1, 5, 10), + datetime(2023, 5, 1, 2, 5, 0), + datetime(2023, 5, 1, 3, 5, 30), + datetime(2023, 5, 1, 4, 5, 0), + datetime(2023, 5, 1, 5, 5, 0), + datetime(2023, 5, 1, 6, 5, 0), + datetime(2023, 5, 1, 7, 5, 0), + datetime(2023, 5, 1, 8, 5, 5), + datetime(2023, 5, 1, 9, 5, 0), + datetime(2023, 5, 1, 11, 5, 0), + ] + + data = [ + [100, 100, 50, 100], + [106, 100, 99.99, 100], + [107, 100, 99, 100], + [103, 100, 99, 100], + [102, 100, 100, 100], + [103, 100, 100, 100], + [103, 100, 100, 100], + [99, 100, 98, 100], + [100, 100, 55, 100], + [101, 100, 30, 100], + [101, 100, 90, 100], + ] + + df = pd.DataFrame(data, columns=points, index=timestamp) + expected_results = pd.Series( + [ + True, + True, + True, + False, + True, + True, + True, + True, + True, + True, + False, + ] + ) + + verification_obj = run_test_verification_with_data( + "LocalLoopSaturationDirectActingMax", df + ) + + results = pd.Series(list(verification_obj.result)) + binaryflag = verification_obj.check_bool() + verification_obj.check_detail() + + self.assertTrue(results.equals(expected_results)) + self.assertFalse(binaryflag) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_localloop_SaturationDirectActingMin.py b/tests/test_localloop_SaturationDirectActingMin.py new file mode 100644 index 00000000..67da85a8 --- /dev/null +++ b/tests/test_localloop_SaturationDirectActingMin.py @@ -0,0 +1,129 @@ +import unittest, sys +import datetime + +sys.path.append("./src") +from lib_unit_test_runner import * +from library import * +import pandas as pd + + +class TestLocalLoopSaturationDirectActingMin(unittest.TestCase): + def test_saturation_damin_pass(self): + points = ["feedback_sensor", "set_point", "cmd", "cmd_min"] + timestamp = [ + datetime(2023, 5, 1, 0, 5, 0), + datetime(2023, 5, 1, 1, 5, 10), + datetime(2023, 5, 1, 2, 5, 0), + datetime(2023, 5, 1, 3, 5, 30), + datetime(2023, 5, 1, 4, 5, 0), + datetime(2023, 5, 1, 5, 5, 0), + datetime(2023, 5, 1, 6, 5, 0), + datetime(2023, 5, 1, 7, 5, 0), + datetime(2023, 5, 1, 8, 5, 5), + datetime(2023, 5, 1, 9, 5, 0), + datetime(2023, 5, 1, 10, 5, 0), + ] + + data = [ + [100, 100, 50, 0], + [94, 100, 0.01, 0], + [93, 100, 0, 0], + [97, 100, 0, 0], + [98, 100, 0, 0], + [97, 100, 0, 0], + [97, 100, 0, 0], + [101, 100, 2, 0], + [100, 100, 50, 0], + [100, 100, 30, 0], + [100, 100, 0, 0], + ] + + df = pd.DataFrame(data, columns=points, index=timestamp) + expected_results = pd.Series( + [ + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + ] + ) + + verification_obj = run_test_verification_with_data( + "LocalLoopSaturationDirectActingMin", df + ) + + results = pd.Series(list(verification_obj.result)) + binaryflag = verification_obj.check_bool() + verification_obj.check_detail() + + self.assertTrue(results.equals(expected_results)) + self.assertTrue(binaryflag) + + def test_saturation_damax_pass(self): + points = ["feedback_sensor", "set_point", "cmd", "cmd_min"] + timestamp = [ + datetime(2023, 5, 1, 0, 5, 0), + datetime(2023, 5, 1, 1, 5, 10), + datetime(2023, 5, 1, 2, 5, 0), + datetime(2023, 5, 1, 3, 5, 30), + datetime(2023, 5, 1, 4, 5, 0), + datetime(2023, 5, 1, 5, 5, 0), + datetime(2023, 5, 1, 6, 5, 0), + datetime(2023, 5, 1, 7, 5, 0), + datetime(2023, 5, 1, 8, 5, 5), + datetime(2023, 5, 1, 9, 5, 0), + datetime(2023, 5, 1, 11, 5, 0), + ] + + data = [ + [100, 100, 50, 0], + [94, 100, 0.01, 0], + [93, 100, 1, 0], + [97, 100, 1, 0], + [98, 100, 0, 0], + [97, 100, 0, 0], + [97, 100, 0, 0], + [101, 100, 2, 0], + [100, 100, 55, 0], + [99, 100, 30, 0], + [99, 100, 90, 0], + ] + + df = pd.DataFrame(data, columns=points, index=timestamp) + expected_results = pd.Series( + [ + True, + True, + True, + False, + True, + True, + True, + True, + True, + True, + False, + ] + ) + + verification_obj = run_test_verification_with_data( + "LocalLoopSaturationDirectActingMin", df + ) + + results = pd.Series(list(verification_obj.result)) + binaryflag = verification_obj.check_bool() + verification_obj.check_detail() + + self.assertTrue(results.equals(expected_results)) + self.assertFalse(binaryflag) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_localloop_SaturationReverseActingMax.py b/tests/test_localloop_SaturationReverseActingMax.py new file mode 100644 index 00000000..2aaf79b9 --- /dev/null +++ b/tests/test_localloop_SaturationReverseActingMax.py @@ -0,0 +1,129 @@ +import unittest, sys +import datetime + +sys.path.append("./src") +from lib_unit_test_runner import * +from library import * +import pandas as pd + + +class TestLocalLoopSaturationReverseActingMax(unittest.TestCase): + def test_saturation_ramax_pass(self): + points = ["feedback_sensor", "set_point", "cmd", "cmd_max"] + timestamp = [ + datetime(2023, 5, 1, 0, 5, 0), + datetime(2023, 5, 1, 1, 5, 10), + datetime(2023, 5, 1, 2, 5, 0), + datetime(2023, 5, 1, 3, 5, 30), + datetime(2023, 5, 1, 4, 5, 0), + datetime(2023, 5, 1, 5, 5, 0), + datetime(2023, 5, 1, 6, 5, 0), + datetime(2023, 5, 1, 7, 5, 0), + datetime(2023, 5, 1, 8, 5, 5), + datetime(2023, 5, 1, 9, 5, 0), + datetime(2023, 5, 1, 10, 5, 0), + ] + + data = [ + [100, 100, 50, 100], + [94, 100, 99.99, 100], + [93, 100, 100, 100], + [97, 100, 100, 100], + [98, 100, 100, 100], + [97, 100, 100, 100], + [97, 100, 100, 100], + [101, 100, 98, 100], + [100, 100, 55, 100], + [100, 100, 30, 100], + [100, 100, 100, 100], + ] + + df = pd.DataFrame(data, columns=points, index=timestamp) + expected_results = pd.Series( + [ + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + ] + ) + + verification_obj = run_test_verification_with_data( + "LocalLoopSaturationReverseActingMax", df + ) + + results = pd.Series(list(verification_obj.result)) + binaryflag = verification_obj.check_bool() + verification_obj.check_detail() + + self.assertTrue(results.equals(expected_results)) + self.assertTrue(binaryflag) + + def test_saturation_damax_pass(self): + points = ["feedback_sensor", "set_point", "cmd", "cmd_max"] + timestamp = [ + datetime(2023, 5, 1, 0, 5, 0), + datetime(2023, 5, 1, 1, 5, 10), + datetime(2023, 5, 1, 2, 5, 0), + datetime(2023, 5, 1, 3, 5, 30), + datetime(2023, 5, 1, 4, 5, 0), + datetime(2023, 5, 1, 5, 5, 0), + datetime(2023, 5, 1, 6, 5, 0), + datetime(2023, 5, 1, 7, 5, 0), + datetime(2023, 5, 1, 8, 5, 5), + datetime(2023, 5, 1, 9, 5, 0), + datetime(2023, 5, 1, 11, 5, 0), + ] + + data = [ + [100, 100, 50, 100], + [94, 100, 99.99, 100], + [93, 100, 99, 100], + [97, 100, 99, 100], + [98, 100, 100, 100], + [97, 100, 100, 100], + [97, 100, 100, 100], + [101, 100, 98, 100], + [100, 100, 55, 100], + [99, 100, 30, 100], + [99, 100, 90, 100], + ] + + df = pd.DataFrame(data, columns=points, index=timestamp) + expected_results = pd.Series( + [ + True, + True, + True, + False, + True, + True, + True, + True, + True, + True, + False, + ] + ) + + verification_obj = run_test_verification_with_data( + "LocalLoopSaturationReverseActingMax", df + ) + + results = pd.Series(list(verification_obj.result)) + binaryflag = verification_obj.check_bool() + verification_obj.check_detail() + + self.assertTrue(results.equals(expected_results)) + self.assertFalse(binaryflag) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_localloop_SaturationReverseActingMin.py b/tests/test_localloop_SaturationReverseActingMin.py new file mode 100644 index 00000000..2d2525a7 --- /dev/null +++ b/tests/test_localloop_SaturationReverseActingMin.py @@ -0,0 +1,129 @@ +import unittest, sys +import datetime + +sys.path.append("./src") +from lib_unit_test_runner import * +from library import * +import pandas as pd + + +class TestLocalLoopSaturationReverseActingMin(unittest.TestCase): + def test_saturation_ramin_pass(self): + points = ["feedback_sensor", "set_point", "cmd", "cmd_min"] + timestamp = [ + datetime(2023, 5, 1, 0, 5, 0), + datetime(2023, 5, 1, 1, 5, 10), + datetime(2023, 5, 1, 2, 5, 0), + datetime(2023, 5, 1, 3, 5, 30), + datetime(2023, 5, 1, 4, 5, 0), + datetime(2023, 5, 1, 5, 5, 0), + datetime(2023, 5, 1, 6, 5, 0), + datetime(2023, 5, 1, 7, 5, 0), + datetime(2023, 5, 1, 8, 5, 5), + datetime(2023, 5, 1, 9, 5, 0), + datetime(2023, 5, 1, 10, 5, 0), + ] + + data = [ + [100, 100, 50, 0], + [106, 100, 0.01, 0], + [107, 100, 0, 0], + [103, 100, 0, 0], + [102, 100, 0, 0], + [103, 100, 0, 0], + [103, 100, 0, 0], + [99, 100, 2, 0], + [100, 100, 55, 0], + [100, 100, 30, 0], + [100, 100, 0, 0], + ] + + df = pd.DataFrame(data, columns=points, index=timestamp) + expected_results = pd.Series( + [ + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + ] + ) + + verification_obj = run_test_verification_with_data( + "LocalLoopSaturationReverseActingMin", df + ) + + results = pd.Series(list(verification_obj.result)) + binaryflag = verification_obj.check_bool() + verification_obj.check_detail() + + self.assertTrue(results.equals(expected_results)) + self.assertTrue(binaryflag) + + def test_saturation_ramin_fail(self): + points = ["feedback_sensor", "set_point", "cmd", "cmd_min"] + timestamp = [ + datetime(2023, 5, 1, 0, 5, 0), + datetime(2023, 5, 1, 1, 5, 10), + datetime(2023, 5, 1, 2, 5, 0), + datetime(2023, 5, 1, 3, 5, 30), + datetime(2023, 5, 1, 4, 5, 0), + datetime(2023, 5, 1, 5, 5, 0), + datetime(2023, 5, 1, 6, 5, 0), + datetime(2023, 5, 1, 7, 5, 0), + datetime(2023, 5, 1, 8, 5, 5), + datetime(2023, 5, 1, 9, 5, 0), + datetime(2023, 5, 1, 11, 5, 0), + ] + + data = [ + [100, 100, 50, 0], + [106, 100, 0.01, 0], + [107, 100, 1, 0], + [103, 100, 1, 0], + [102, 100, 0, 0], + [103, 100, 0, 0], + [103, 100, 0, 0], + [99, 100, 2, 0], + [100, 100, 55, 0], + [101, 100, 30, 0], + [101, 100, 90, 0], + ] + + df = pd.DataFrame(data, columns=points, index=timestamp) + expected_results = pd.Series( + [ + True, + True, + True, + False, + True, + True, + True, + True, + True, + True, + False, + ] + ) + + verification_obj = run_test_verification_with_data( + "LocalLoopSaturationReverseActingMin", df + ) + + results = pd.Series(list(verification_obj.result)) + binaryflag = verification_obj.check_bool() + verification_obj.check_detail() + + self.assertTrue(results.equals(expected_results)) + self.assertFalse(binaryflag) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_localloop_SetPointTracking.py b/tests/test_localloop_SetPointTracking.py new file mode 100644 index 00000000..1196dc53 --- /dev/null +++ b/tests/test_localloop_SetPointTracking.py @@ -0,0 +1,241 @@ +import unittest, sys +import datetime + +sys.path.append("./src") +from lib_unit_test_runner import * +from library import * +import pandas as pd + + +class TestLocalLoopSetPointTracking(unittest.TestCase): + def test_set_point_tracking_pass(self): + points = ["feedback_sensor", "set_point"] + timestamp = [ + datetime(2023, 5, 1, 18, 0, 0), + datetime(2023, 5, 1, 18, 1, 0), + datetime(2023, 5, 1, 18, 2, 0), + datetime(2023, 5, 1, 18, 3, 0), + datetime(2023, 5, 1, 18, 4, 0), + datetime(2023, 5, 1, 18, 5, 0), + datetime(2023, 5, 1, 18, 6, 0), + datetime(2023, 5, 1, 18, 7, 0), + datetime(2023, 5, 1, 18, 8, 0), + datetime(2023, 5, 1, 18, 9, 0), + datetime(2023, 5, 1, 18, 10, 0), + datetime(2023, 5, 1, 18, 11, 0), + datetime(2023, 5, 1, 18, 12, 0), + datetime(2023, 5, 1, 18, 13, 0), + datetime(2023, 5, 1, 18, 14, 0), + datetime(2023, 5, 1, 18, 15, 0), + datetime(2023, 5, 1, 18, 16, 0), + datetime(2023, 5, 1, 18, 17, 0), + datetime(2023, 5, 1, 18, 18, 0), + datetime(2023, 5, 1, 18, 19, 0), + datetime(2023, 5, 1, 18, 20, 0), + ] + + data = [ + [96, 100], + [97, 100], + [98, 100], + [99, 100], + [100, 100], + [101, 100], + [102, 100], + [103, 100], + [104, 100], + [105.1, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + ] + + df = pd.DataFrame(data, columns=points, index=timestamp) + expected_results = pd.Series( + [ + True, + True, + True, + True, + True, + True, + True, + True, + True, + False, + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + ] + ) + + verification_obj = run_test_verification_with_data( + "LocalLoopSetPointTracking", df + ) + + results = pd.Series(list(verification_obj.result)) + binaryflag = verification_obj.check_bool() + + self.assertTrue(results.equals(expected_results)) + self.assertTrue(binaryflag) + + def test_set_point_tracking_fail_samplepct(self): + points = ["feedback_sensor", "set_point"] + timestamp = [ + datetime(2023, 5, 1, 18, 0, 0), + datetime(2023, 5, 1, 18, 1, 0), + datetime(2023, 5, 1, 18, 2, 0), + datetime(2023, 5, 1, 18, 3, 0), + datetime(2023, 5, 1, 18, 4, 0), + datetime(2023, 5, 1, 18, 5, 0), + datetime(2023, 5, 1, 18, 6, 0), + datetime(2023, 5, 1, 18, 7, 0), + datetime(2023, 5, 1, 18, 8, 0), + datetime(2023, 5, 1, 18, 9, 0), + ] + + data = [ + [96, 100], + [97, 100], + [98, 100], + [99, 100], + [100, 100], + [101, 100], + [102, 100], + [103, 100], + [104, 100], + [105.1, 100], + ] + + df = pd.DataFrame(data, columns=points, index=timestamp) + expected_results = pd.Series( + [ + True, + True, + True, + True, + True, + True, + True, + True, + True, + False, + ] + ) + + verification_obj = run_test_verification_with_data( + "LocalLoopSetPointTracking", df + ) + + results = pd.Series(list(verification_obj.result)) + binaryflag = verification_obj.check_bool() + + self.assertTrue(results.equals(expected_results)) + self.assertFalse(binaryflag) + + def test_set_point_tracking_pass(self): + points = ["feedback_sensor", "set_point"] + timestamp = [ + datetime(2023, 5, 1, 18, 0, 0), + datetime(2023, 5, 1, 18, 1, 0), + datetime(2023, 5, 1, 18, 2, 0), + datetime(2023, 5, 1, 18, 3, 0), + datetime(2023, 5, 1, 18, 4, 0), + datetime(2023, 5, 1, 18, 5, 0), + datetime(2023, 5, 1, 18, 6, 0), + datetime(2023, 5, 1, 18, 7, 0), + datetime(2023, 5, 1, 18, 8, 0), + datetime(2023, 5, 1, 18, 9, 0), + datetime(2023, 5, 1, 18, 10, 0), + datetime(2023, 5, 1, 18, 11, 0), + datetime(2023, 5, 1, 18, 12, 0), + datetime(2023, 5, 1, 18, 13, 0), + datetime(2023, 5, 1, 18, 14, 0), + datetime(2023, 5, 1, 18, 15, 0), + datetime(2023, 5, 1, 18, 16, 0), + datetime(2023, 5, 1, 18, 17, 0), + datetime(2023, 5, 1, 18, 18, 0), + datetime(2023, 5, 1, 18, 19, 0), + datetime(2023, 5, 1, 18, 20, 0), + ] + + data = [ + [96, 100], + [97, 100], + [98, 100], + [99, 100], + [100, 100], + [101, 100], + [102, 100], + [103, 100], + [104, 100], + [105.1, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + ] + + df = pd.DataFrame(data, columns=points, index=timestamp) + expected_results = pd.Series( + [ + True, + True, + True, + True, + True, + True, + True, + True, + True, + False, + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + ] + ) + + verification_obj = run_test_verification_with_data( + "LocalLoopSetPointTracking", df + ) + + results = pd.Series(list(verification_obj.result)) + binaryflag = verification_obj.check_bool() + + self.assertTrue(results.equals(expected_results)) + self.assertTrue(binaryflag) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_localloop_UnmetHours.py b/tests/test_localloop_UnmetHours.py new file mode 100644 index 00000000..08002e18 --- /dev/null +++ b/tests/test_localloop_UnmetHours.py @@ -0,0 +1,185 @@ +import unittest, sys +import datetime + +sys.path.append("./src") +from lib_unit_test_runner import * +from library import * +import pandas as pd + + +class TestLocalLoopUnmetHours(unittest.TestCase): + def test_unmet_hours_pass(self): + points = ["feedback_sensor", "set_point"] + timestamp = [ + datetime(2023, 5, 1, 0, 5, 0), + datetime(2023, 5, 1, 1, 5, 10), + datetime(2023, 5, 1, 2, 5, 20), + datetime(2023, 5, 1, 3, 5, 30), + datetime(2023, 5, 1, 4, 5, 0), + datetime(2023, 5, 1, 5, 5, 0), + datetime(2023, 5, 1, 6, 5, 0), + datetime(2023, 5, 1, 7, 5, 0), + datetime(2023, 5, 1, 8, 5, 5), + datetime(2023, 5, 1, 9, 5, 0), + datetime(2023, 5, 1, 10, 5, 0), + datetime(2023, 5, 1, 11, 5, 0), + datetime(2023, 5, 1, 12, 5, 0), + datetime(2023, 5, 1, 13, 5, 0), + datetime(2023, 5, 1, 14, 5, 0), + datetime(2023, 5, 1, 15, 5, 0), + datetime(2023, 5, 1, 16, 5, 0), + datetime(2023, 5, 1, 17, 5, 0), + datetime(2023, 5, 1, 18, 5, 0), + datetime(2023, 5, 1, 19, 5, 0), + datetime(2023, 5, 1, 20, 5, 0), + ] + + data = [ + [96, 100], + [97, 100], + [98, 100], + [99, 100], + [100, 100], + [101, 100], + [102, 100], + [103, 100], + [106, 100], + [105.1, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + ] + + df = pd.DataFrame(data, columns=points, index=timestamp) + expected_results = pd.Series( + [ + True, + True, + True, + True, + True, + True, + True, + True, + False, + False, + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + ] + ) + + verification_obj = run_test_verification_with_data("LocalLoopUnmetHours", df) + + results = pd.Series(list(verification_obj.result)) + binaryflag = verification_obj.check_bool() + verification_obj.check_detail() + + self.assertTrue(results.equals(expected_results)) + self.assertTrue(binaryflag) + + def test_unmet_hours_pass(self): + points = ["feedback_sensor", "set_point"] + timestamp = [ + datetime(2023, 5, 1, 0, 5, 0), + datetime(2023, 5, 1, 1, 5, 10), + datetime(2023, 5, 1, 2, 5, 20), + datetime(2023, 5, 1, 3, 5, 30), + datetime(2023, 5, 1, 4, 5, 0), + datetime(2023, 5, 1, 5, 5, 0), + datetime(2023, 5, 1, 6, 5, 0), + datetime(2023, 5, 1, 7, 5, 0), + datetime(2023, 5, 1, 7, 5, 5), + datetime(2023, 5, 1, 9, 5, 0), + datetime(2023, 5, 1, 10, 5, 0), + datetime(2023, 5, 1, 11, 5, 0), + datetime(2023, 5, 1, 12, 5, 0), + datetime(2023, 5, 1, 13, 5, 0), + datetime(2023, 5, 1, 14, 5, 0), + datetime(2023, 5, 1, 15, 5, 0), + datetime(2023, 5, 1, 16, 5, 0), + datetime(2023, 5, 1, 17, 5, 0), + datetime(2023, 5, 1, 18, 5, 0), + datetime(2023, 5, 1, 19, 5, 0), + datetime(2023, 5, 1, 20, 5, 0), + ] + + data = [ + [96, 100], + [97, 100], + [98, 100], + [99, 100], + [100, 100], + [101, 100], + [102, 100], + [103, 100], + [106, 100], + [105.1, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + [104, 100], + ] + + df = pd.DataFrame(data, columns=points, index=timestamp) + expected_results = pd.Series( + [ + True, + True, + True, + True, + True, + True, + True, + True, + False, + False, + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + ] + ) + + verification_obj = run_test_verification_with_data("LocalLoopUnmetHours", df) + + results = pd.Series(list(verification_obj.result)) + binaryflag = verification_obj.check_bool() + verification_obj.check_detail() + + self.assertTrue(results.equals(expected_results)) + self.assertFalse(binaryflag) + + +if __name__ == "__main__": + unittest.main()