diff --git a/README.md b/README.md index 724d3ff..a8210fc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # pysolorie +[![CodeQL](https://github.com/aaghamohammadi/pysolorie/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main)](https://github.com/aaghamohammadi/pysolorie/actions/workflows/github-code-scanning/codeql) [![Quality Checks](https://github.com/aaghamohammadi/pysolorie/actions/workflows/quality_checks.yml/badge.svg?branch=main)](https://github.com/aaghamohammadi/pysolorie/actions/workflows/quality_checks.yml) [![Publish](https://github.com/aaghamohammadi/pysolorie/actions/workflows/publish.yml/badge.svg?branch=main)](https://github.com/aaghamohammadi/pysolorie/actions/workflows/publish.yml) ![GitHub License](https://img.shields.io/github/license/aaghamohammadi/pysolorie) @@ -9,6 +10,9 @@ ![PyPI - Format](https://img.shields.io/pypi/format/pysolorie) ![PyPI - Status](https://img.shields.io/pypi/status/pysolorie) [![codecov](https://codecov.io/gh/aaghamohammadi/pysolorie/graph/badge.svg?token=TF9E8Y3Q67)](https://codecov.io/gh/aaghamohammadi/pysolorie) +![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white) +![code style: black](https://img.shields.io/badge/code%20style-black-black) + **pysolorie** stands for **Py**thon **Sol**ar **Orie**ntation Analysis of Solar Panel. It is a Python library designed to help you analyze the orientation of solar panels. @@ -21,14 +25,15 @@ How can one maximize the solar irradiation energy received by a solar panel? ``pysolorie`` is a library designed to help you find this optimal orientation. Its features include, but are not limited to: -- Finding the optimal orientation for a fixed solar panel under the assumption of a clear-sky model. -- Plotting the Optimal Orientation for a range of days. -- Generating a CSV Report of the optimal orientation for a range of days. -- Calculating the sunrise and sunset hour angles for a given day. -- Utilizing the Hottel's Model to estimate clear-sky beam radiation transmittance. +- Finding the optimal orientation for a fixed solar panel, assuming a clear-sky model. +- Plotting the optimal orientation over a range of days. +- Plotting the total direct irradiation over a range of days. +- Generating a CSV report detailing the optimal orientation over a range of days. +- Calculating the sunrise and sunset hour angles for a specific day. +- Utilizing Hottel’s Model to estimate the transmittance of clear-sky beam radiation. - Calculating the solar zenith angle. - Calculating the solar time. -- Calculating Solar Declination and Hour Angle. +- Calculating solar declination and hour angle. diff --git a/docs/changelog.rst b/docs/changelog.rst index a9765e1..3196085 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,41 @@ Changelog ========= +Version 1.5.0 +------------- + +Release date: 2023-12-20 + +Added +^^^^^ +- Added badges for CodeQL, pre-commit, and code style (black) in README.md, enhancing the visibility of code quality and style adherence. +- Implemented ``generate_optimal_orientation_json_report`` method for JSON report generation in ``ReportGenerator``. +- Developed ``generate_optimal_orientation_xml_report`` method in ``ReportGenerator`` for producing XML formatted reports. +- Expanded ``test_pysolorie.py`` with tests for JSON (``test_generate_optimal_orientation_json_report``) and XML (``test_generate_optimal_orientation_xml_report``) report generation. + +Changed +^^^^^^^ +- Updated ``plot_total_direct_irradiation`` method's ``ylabel`` argument to use "Megajoules per square meter" instead of "MW/m²" for clarity and accuracy in ``plotter.py``. +- Refactored ``plot_total_direct_irradiation`` in ``Plotter`` to use a private method for calculating optimal orientations, streamlining the plotting process. + +Fixed +^^^^^ +- Altered the ``Plotter`` methods to handle axis labels and titles through dynamic ``plot_kwargs``, making the labeling more robust and customizable. +- Harmonized and corrected unit values and labelings across the entire codebase and documentation for consistency and accuracy. +- Refined the ``ReportGenerator`` generate methods' docstrings, clearly specifying the return value unit as "Megajoules per square meter". +- Changed the calculation of the solar irradiance formula in ``SolarIrradiance`` from ``0.33`` to ``0.033`` to correct the eccentricity correction factor according to established astronomical equations. + + +Documentation +^^^^^^^^^^^^^ +- Enhanced documentation in ``getting_started.rst`` with examples and instructions for the new JSON and XML report generation methods. +- Altered the representation of solar irradiance units in documentation to match the codebase changes. + +Testing +^^^^^^^ +- Enriched ``test_pysolorie.py`` with further assertions for newly added JSON and XML report functionalities, ensuring correct report file creation and data integrity. + + Version 1.4.0 ------------- diff --git a/docs/getting_started.rst b/docs/getting_started.rst index f70b972..ccd086a 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -92,6 +92,49 @@ the optimal orientation for a range of days. The CSV file will be saved to the specified path. +Generating a JSON Report +------------------------ + +The ``generate_optimal_orientation_json_report`` method generates a JSON report of +the optimal orientation for a range of days. + +.. code-block:: python + + from pysolorie import ReportGenerator, IrradiationCalculator + from pathlib import Path + + # Create a report generator and an irradiation calculator + report_generator = ReportGenerator() + irradiation_calculator = IrradiationCalculator("MIDLATITUDE SUMMER", 1200, 35.6892) + + # Generate a JSON report for days 60 to 70 + report_generator.generate_optimal_orientation_json_report(Path('results.json'), irradiation_calculator, 60, 70) + +The JSON file will be saved to the specified path. + + +Generating an XML Report +------------------------ + +The ``generate_optimal_orientation_xml_report`` method generates an XML report of +the optimal orientation for a range of days. + +.. code-block:: python + + from pysolorie import ReportGenerator, IrradiationCalculator + from pathlib import Path + + # Create a report generator and an irradiation calculator + report_generator = ReportGenerator() + irradiation_calculator = IrradiationCalculator("MIDLATITUDE SUMMER", 1200, 35.6892) + + # Generate an XML report for days 60 to 70 + report_generator.generate_optimal_orientation_xml_report(Path('results.xml'), irradiation_calculator, 60, 70) + +The XML file will be saved to the specified path. + + + Plotting the Optimal Orientation -------------------------------- @@ -129,7 +172,7 @@ The ``plot_total_direct_irradiation`` method plots the total direct irradiation irradiation_calculator = IrradiationCalculator("MIDLATITUDE SUMMER", 1200, 35.6892) # Plot the total direct irradiation for days 60 to 70 - plotter.plot_total_direct_irradiation(irradiation_calculator, 60, 70, Path('results.png'), plot_kwargs={'xlabel': 'Day', 'ylabel': 'Total Direct Irradiation (MW/m²)', 'title': 'Total Direct Irradiation', "figsize": (16,9)}, savefig_kwargs={'dpi': 300}) + plotter.plot_total_direct_irradiation(irradiation_calculator, 60, 70, Path('results.png'), plot_kwargs={'xlabel': 'Day', 'ylabel': 'Total Direct Irradiation (Megajoules per square meter)', 'title': 'Total Direct Irradiation', "figsize": (16,9)}, savefig_kwargs={'dpi': 300}) The plot will be saved to the specified path. The ``plot_kwargs`` and ``savefig_kwargs`` parameters can be used to customize the plot and the savefig function, respectively. If no path is provided, the plot will be displayed but not saved. @@ -158,8 +201,8 @@ for a given day of the year. print(f"Sunset hour angle: {sunset_hour_angle}") -Calculating the Zenith Angle ----------------------------- +Calculating the Solar Zenith Angle +---------------------------------- The ``calculate_zenith_angle`` method calculates the zenith angle given the day of the year and solar time. diff --git a/setup.cfg b/setup.cfg index 1f488e1..522cfc7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = pysolorie -version = 1.4.0 +version = 1.5.0 description = Orientation Analysis of Solar Panel long_description = file: README.md long_description_content_type = text/markdown diff --git a/src/pysolorie/irradiance.py b/src/pysolorie/irradiance.py index f621f89..5191313 100644 --- a/src/pysolorie/irradiance.py +++ b/src/pysolorie/irradiance.py @@ -46,7 +46,7 @@ def calculate_extraterrestrial_irradiance(self, day_of_year: int) -> float: .. math:: I = SC - \times (1 + 0.33 \times \cos (\frac{2\pi~n}{365})) + \times (1 + 0.033 \times \cos (\frac{2\pi~n}{365})) | - :math:`SC` is the average solar radiation arriving outside diff --git a/src/pysolorie/numerical_integration.py b/src/pysolorie/numerical_integration.py index e050933..53fa0bb 100644 --- a/src/pysolorie/numerical_integration.py +++ b/src/pysolorie/numerical_integration.py @@ -144,7 +144,7 @@ def calculate_direct_irradiation( :type panel_orientation: float :param day_of_year: The day of the year. :type day_of_year: int - :return: The total direct irradiation. + :return: The total direct irradiation in Megajoules per square meter. :rtype: float """ sunrise_hour_angle, sunset_hour_angle = self._observer.calculate_sunrise_sunset( diff --git a/src/pysolorie/plotter.py b/src/pysolorie/plotter.py index 1a91302..7bfe058 100644 --- a/src/pysolorie/plotter.py +++ b/src/pysolorie/plotter.py @@ -96,16 +96,13 @@ def plot_total_direct_irradiation( :type savefig_kwargs: dict, optional """ - days = [] - total_direct_irradiations = [] - - for day in range(from_day, to_day): - optimal_beta = irradiation_calculator.find_optimal_orientation(day) - total_direct_irradiation = ( - irradiation_calculator.calculate_direct_irradiation(optimal_beta, day) - ) - days.append(day) - total_direct_irradiations.append(total_direct_irradiation) + days, betas = self._calculate_optimal_orientations( + irradiation_calculator, from_day, to_day + ) + total_direct_irradiations = [ + irradiation_calculator.calculate_direct_irradiation(beta, day) + for day, beta in zip(days, betas) + ] plot_kwargs = plot_kwargs if plot_kwargs else {} savefig_kwargs = savefig_kwargs if savefig_kwargs else {} @@ -141,9 +138,9 @@ def _plot( figsize = plot_kwargs.get("figsize", (10, 6)) fig, ax = plt.subplots(figsize=figsize) ax.plot(days, betas) - ax.set_xlabel(plot_kwargs.get("xlabel", "Day")) - ax.set_ylabel(plot_kwargs.get("ylabel", "Beta (degrees)")) - ax.set_title(plot_kwargs.get("title", "Optimal Solar Panel Orientation")) + ax.set_xlabel(plot_kwargs.get("xlabel", "X Axis Title")) + ax.set_ylabel(plot_kwargs.get("ylabel", "Y Axis Title")) + ax.set_title(plot_kwargs.get("title", "Title")) ax.grid(True) if path is not None: diff --git a/src/pysolorie/report.py b/src/pysolorie/report.py index e743a84..03f0fca 100644 --- a/src/pysolorie/report.py +++ b/src/pysolorie/report.py @@ -13,8 +13,11 @@ # limitations under the License. import csv +import json import logging +import xml.etree.ElementTree as ET from pathlib import Path +from typing import Dict, List, Union from .logger import logger_decorator from .numerical_integration import IrradiationCalculator @@ -26,6 +29,55 @@ class ReportGenerator: of solar panels and the total direct irradiation. """ + def _calculate_optimal_orientation_and_irradiation( + self, + irradiation_calculator: IrradiationCalculator, + from_day: int, + to_day: int, + ) -> List[Dict[str, Union[int, float]]]: + r""" + This private method calculates the optimal solar panel orientation and + total direct irradiation for a range of days. + + :param irradiation_calculator: An instance of the IrradiationCalculator class. + :type irradiation_calculator: pysolorie.IrradiationCalculator + :param from_day: The starting day of the report. + :type from_day: int + :param to_day: The ending day of the report. + :type to_day: int + :return: A list of dictionaries, each containing the day, + optimal orientation (beta), + and total direct irradiation. + :rtype: List[Dict[str, Union[int, float]]] + """ + data = [] + + for day in range(from_day, to_day): + beta = irradiation_calculator.find_optimal_orientation(day) + total_direct_irradiation = ( + irradiation_calculator.calculate_direct_irradiation(beta, day) + ) + logger = logging.getLogger( + self._calculate_optimal_orientation_and_irradiation.__name__ + ) + logger.info( + f"On day {day}, the solar panel's optimal orientation is " + f"{beta} degrees, and the total direct irradiation is " + f"{total_direct_irradiation} Megajoules per square meter." + ) + + # Append the result to the data list + data.append( + { + "Day": day, + "Beta (degrees)": beta, + "Total Direct Irradiation " + "(Megajoules per square meter)": total_direct_irradiation, + } + ) + + return data + @logger_decorator def generate_optimal_orientation_csv_report( self, @@ -35,11 +87,12 @@ def generate_optimal_orientation_csv_report( to_day: int, ) -> None: r""" - This method generates a report of - optimal solar panel orientation in CSV format. + This method generates a report of optimal solar panel orientation in CSV format. + It uses the ``_calculate_optimal_orientation_and_irradiation`` + method to get the data. :param path: A Path object that points to the CSV file - where the report will be written. + where the report will be written. :type path: Path :param irradiation_calculator: An instance of the IrradiationCalculator class. :type irradiation_calculator: pysolorie.IrradiationCalculator @@ -48,25 +101,104 @@ def generate_optimal_orientation_csv_report( :param to_day: The ending day of the report. :type to_day: int """ + data = self._calculate_optimal_orientation_and_irradiation( + irradiation_calculator, from_day, to_day + ) + with open(path, "w", newline="") as file: writer = csv.writer(file) writer.writerow( - ["Day", "Beta (degrees)", "Total Direct Irradiation (MW/m²)"] + [ + "Day", + "Beta (degrees)", + "Total Direct Irradiation (Megajoules per square meter)", + ] ) - for day in range(from_day, to_day): - beta = irradiation_calculator.find_optimal_orientation(day) - total_direct_irradiation = ( - irradiation_calculator.calculate_direct_irradiation(beta, day) - ) - logger = logging.getLogger( - self.generate_optimal_orientation_csv_report.__name__ - ) - logger.info( - f"On day {day}, the solar panel's optimal orientation is " - f"{beta} degrees, and the total direct irradiation is " - f"{total_direct_irradiation} MW/m²." + for row in data: + writer.writerow( + [ + row["Day"], + row["Beta (degrees)"], + row["Total Direct Irradiation (Megajoules per square meter)"], + ] ) - # Write the result to the CSV file - writer.writerow([day, beta, total_direct_irradiation]) + @logger_decorator + def generate_optimal_orientation_json_report( + self, + path: Path, + irradiation_calculator: IrradiationCalculator, + from_day: int, + to_day: int, + ) -> None: + r""" + This method generates a report of optimal + solar panel orientation in JSON format. + It uses the ``_calculate_optimal_orientation_and_irradiation`` + method to get the data. + + :param path: A Path object that points to the JSON file + where the report will be written. + :type path: Path + :param irradiation_calculator: An instance of the IrradiationCalculator class. + :type irradiation_calculator: pysolorie.IrradiationCalculator + :param from_day: The starting day of the report. + :type from_day: int + :param to_day: The ending day of the report. + :type to_day: int + """ + data = self._calculate_optimal_orientation_and_irradiation( + irradiation_calculator, from_day, to_day + ) + + # Write the data list to the JSON file + with open(path, "w") as file: + json.dump(data, file, indent=4) + + @logger_decorator + def generate_optimal_orientation_xml_report( + self, + path: Path, + irradiation_calculator: IrradiationCalculator, + from_day: int, + to_day: int, + ) -> None: + r""" + This method generates a report of optimal solar panel orientation in XML format. + It uses the ``_calculate_optimal_orientation_and_irradiation`` + method to get the data. + + :param path: A Path object that points to the XML file + where the report will be written. + :type path: Path + :param irradiation_calculator: An instance of the IrradiationCalculator class. + :type irradiation_calculator: pysolorie.IrradiationCalculator + :param from_day: The starting day of the report. + :type from_day: int + :param to_day: The ending day of the report. + :type to_day: int + """ + data = self._calculate_optimal_orientation_and_irradiation( + irradiation_calculator, from_day, to_day + ) + + # Create the root element + root = ET.Element("Report") + + for row in data: + # Create a 'Day' element for each day + day_element = ET.SubElement(root, "Day") + day_element.set("id", str(row["Day"])) + + # Create 'Beta' and 'TotalDirectIrradiation' elements for each day + beta_element = ET.SubElement(day_element, "Beta") + beta_element.text = str(row["Beta (degrees)"]) + tdi_element = ET.SubElement(day_element, "TotalDirectIrradiation") + tdi_element.text = str( + row["Total Direct Irradiation (Megajoules per square meter)"] + ) + + # Write the XML data to the file + tree = ET.ElementTree(root) + tree.write(path) diff --git a/tests/test_pysolorie.py b/tests/test_pysolorie.py index 5c2407e..5161812 100644 --- a/tests/test_pysolorie.py +++ b/tests/test_pysolorie.py @@ -12,7 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. import csv +import json import math +import xml.etree.ElementTree as ET from pathlib import Path from typing import Any, Dict, List, Tuple from unittest.mock import MagicMock @@ -362,7 +364,11 @@ def test_generate_optimal_orientation_csv_report(tmpdir) -> None: with open(csv_path, "r") as file: reader = csv.reader(file) header = next(reader) - assert header == ["Day", "Beta (degrees)", "Total Direct Irradiation (MW/m²)"] + assert header == [ + "Day", + "Beta (degrees)", + "Total Direct Irradiation (Megajoules per square meter)", + ] for i, row in enumerate(reader, start=from_day): day, beta, total_direct_irradiation = ( int(row[0]), @@ -383,6 +389,93 @@ def test_generate_optimal_orientation_csv_report(tmpdir) -> None: ) +def test_generate_optimal_orientation_json_report(tmpdir) -> None: + # Create a temporary directory for the test + temp_dir: Path = Path(tmpdir) + + # Initialize the ReportGenerator + report_generator: ReportGenerator = ReportGenerator() + + # Initialize the IrradiationCalculator for Tehran + irradiation_calculator: IrradiationCalculator = IrradiationCalculator( + "MIDLATITUDE SUMMER", 1200, 35.6892 + ) + + # Define the path for the JSON file + json_path: Path = temp_dir / "report.json" + from_day: int = 60 + to_day: int = 70 + # Call the method to generate the report + report_generator.generate_optimal_orientation_json_report( + json_path, irradiation_calculator, from_day, to_day + ) + + # Check the JSON file + with open(json_path, "r") as file: + data = json.load(file) + for i, row in enumerate(data, start=from_day): + day, beta, total_direct_irradiation = ( + row["Day"], + row["Beta (degrees)"], + row["Total Direct Irradiation (Megajoules per square meter)"], + ) + + assert day == i + expected_beta = irradiation_calculator.find_optimal_orientation(i) + expected_total_direct_irradiation = ( + irradiation_calculator.calculate_direct_irradiation(beta, i) + ) + assert pytest.approx(beta, abs=1e-3) == expected_beta + assert ( + pytest.approx(total_direct_irradiation, abs=1e-3) + == expected_total_direct_irradiation + ) + + +def test_generate_optimal_orientation_xml_report(tmpdir) -> None: + # Create a temporary directory for the test + temp_dir: Path = Path(tmpdir) + + # Initialize the ReportGenerator + report_generator: ReportGenerator = ReportGenerator() + + # Initialize the IrradiationCalculator for Tehran + irradiation_calculator: IrradiationCalculator = IrradiationCalculator( + "MIDLATITUDE SUMMER", 1200, 35.6892 + ) + + # Define the path for the XML file + xml_path: Path = temp_dir / "report.xml" + from_day: int = 60 + to_day: int = 70 + # Call the method to generate the report + report_generator.generate_optimal_orientation_xml_report( + xml_path, irradiation_calculator, from_day, to_day + ) + + # Check the XML file + tree = ET.parse(xml_path) + root = tree.getroot() + + for i, day_element in enumerate(root.findall("Day"), start=from_day): + day = int(day_element.get("id")) + beta = float(day_element.find("Beta").text) + total_direct_irradiation = float( + day_element.find("TotalDirectIrradiation").text + ) + + assert day == i + expected_beta = irradiation_calculator.find_optimal_orientation(i) + expected_total_direct_irradiation = ( + irradiation_calculator.calculate_direct_irradiation(beta, i) + ) + assert pytest.approx(beta, abs=1e-3) == expected_beta + assert ( + pytest.approx(total_direct_irradiation, abs=1e-3) + == expected_total_direct_irradiation + ) + + def test_plot_optimal_orientation(tmpdir) -> None: # Create a temporary directory for the test temp_dir: Path = Path(tmpdir)