diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index dd84ea7..c578c38 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,10 +1,5 @@ --- name: Bug report -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - --- **Describe the bug** @@ -25,13 +20,6 @@ If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] - -**Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - Version [e.g. 22] **Additional context** diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index bbcbbe7..369a18a 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,10 +1,5 @@ --- name: Feature request -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - --- **Is your feature request related to a problem? Please describe.** diff --git a/.gitignore b/.gitignore index ad4a1f1..9cb2bf7 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,8 @@ poetry.toml pyrightconfig.json # End of https://www.toptal.com/developers/gitignore/api/python + +# joss +paper/media/ +*.jats +*.pdf diff --git a/README.md b/README.md index a8210fc..be61d59 100644 --- a/README.md +++ b/README.md @@ -25,17 +25,63 @@ 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, assuming a clear-sky model. -- Plotting the optimal orientation over a range of days. +- Finding the optimal tilt angle for a fixed solar panel, assuming a clear-sky condition. +- Plotting the optimal tilt angle 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. +- Generating a CSV, JSON, or XML report detailing the optimal tilt angle 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. +- Utilizing Hottel's model to quantify clear-sky conditions and estimate the atmospheric transmission of clear-sky beam radiation. - Calculating the solar zenith angle. - Calculating the solar time. - Calculating solar declination and hour angle. +# How to Install pysolorie +``pysolorie`` requires Python 3.9 or higher. + +The easiest way to install ``pysolorie`` is from PyPI. + +```bash +python3 -m pip install pysolorie +``` + + +## Example Usage +With the pysolorie package, you can plot the optimal orientation of a solar panel given the climate type, altitude, and latitude of the location. For instance, the following code plots the optimal orientation for each day of the year for the city of Tehran. For more examples, please refer to the [Getting started](https://pysolorie.readthedocs.io/en/latest/getting_started.html) section of our documentation. The full [API Reference](https://pysolorie.readthedocs.io/en/latest/reference/modules.html) is also available on our website. + +```python +from pathlib import Path +from pysolorie import IrradiationCalculator, Plotter + +# Instantiate a Plotter object from the pysolorie library +plotter = Plotter() + +# Instantiate an IrradiationCalculator object for the city of Tehran +irradiation_calculator = IrradiationCalculator( + climate_type="MIDLATITUDE SUMMER", + observer_altitude=1200, + observer_latitude=35.6892 +) + + +# Use the plotter to plot the optimal orientation of a solar panel for each day of the year +plotter.plot_optimal_orientation( + irradiation_calculator, + from_day=1, + to_day=365, + path=Path("results.svg"), + plot_kwargs={ + "xlabel": "Day", + "ylabel": "Beta (degrees)", + "title": "Optimal Solar Panel Orientation", + }, + savefig_kwargs={"dpi": 300}, +) +``` + +This figure, generated by the example code, illustrates the optimal orientation of a solar panel for each day of the year in Tehran. The x-axis represents the day of the year, while the y-axis represents the optimal angle (Beta) in degrees. As can be seen, the optimal angle varies throughout the year, highlighting the importance of adjusting the orientation of the solar panel to maximize the energy received. + + ## Documentation diff --git a/docs-requirements.txt b/docs-requirements.txt index cbf1e36..b75b86b 100644 --- a/docs-requirements.txt +++ b/docs-requirements.txt @@ -1,2 +1,3 @@ sphinx +sphinx-copybutton sphinx-rtd-theme diff --git a/docs/_static/images/example_usage.svg b/docs/_static/images/example_usage.svg new file mode 100644 index 0000000..eef5f26 --- /dev/null +++ b/docs/_static/images/example_usage.svg @@ -0,0 +1,1503 @@ + + + + + + + + 2023-12-21T14:10:01.926530 + image/svg+xml + + + Matplotlib v3.7.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/changelog.rst b/docs/changelog.rst index 1020ccb..974f61b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,45 @@ Changelog ========= +Version 1.5.2 +------------- + +Release date: 2023-12-23 + +Changed +^^^^^^^ + +- Refactoring in various modules (``plotter.py``, ``report.py``, ``tests/test_pysolorie.py``, etc.) to improve code clarity and reduce the complexity. +- Renamed "optimal orientation" to "optimal tilt angle" in the README, documentation, and examples to more accurately reflect solar panel positioning. +- Updated and clarified solar irradiance discussion in the paper draft, along with audience benefits, terminology, and code usage in the README and documentation. +- Improved various code samples across the documentation for enhanced clarity and consistency. + +Fixed +^^^^^ + +- Fixed the incorrect setting of logger instances within several classes and refactored the decorating function to correctly set the logger attribute. +- Resolved mypy type checking errors by adding type ignore comments where necessary. + + +Testing +^^^^^^^ + +- Extensive refactoring of test cases, with tests being moved to newly named files according to their respective functionalities. +- Addition of test cases for new refactorings and exception handling enhancements to ensure code reliability and correctness. + +Documentation +^^^^^^^^^^^^^ + +- Amended the Sphinx configuration with the ``sphinx-copybutton`` extension and updated the packages list to include the extension. +- Corrected and improved the structure of documentation files to enhance user understanding and readability. + +Miscellaneous +^^^^^^^^^^^^^ + +- Simplified GitHub issue templates to make them more accessible and concise for users. +- Refinement of content descriptions, departmental affiliations for authors, and enhancement of figure captions in the paper draft. + + Version 1.5.1 ------------- diff --git a/docs/conf.py b/docs/conf.py index 4c5f7f6..f806f45 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,6 +15,7 @@ extensions = [ "sphinx.ext.autodoc", "sphinx.ext.autodoc.typehints", + "sphinx_copybutton", "sphinx.ext.viewcode", ] diff --git a/docs/getting_started.rst b/docs/getting_started.rst index ccd086a..867192e 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -11,13 +11,45 @@ The easiest way to install ``pysolorie`` is from PyPI. .. code-block:: bash - $ python3 -m pip install pysolorie + python3 -m pip install pysolorie +Calculating Total Direct Irradiation for a Year +----------------------------------------------- + +Suppose we aim to calculate the total direct irradiation for a specific location. The following code determines the total direct irradiation received by a solar panel, assuming an optimal tilt angle for each day of the year at a given location. In this scenario, the location is Tehran, Iran. + +.. code-block:: python + + from pysolorie import IrradiationCalculator + + # Instantiate an IrradiationCalculator object for the city of Tehran + calculator = IrradiationCalculator( + climate_type="MIDLATITUDE SUMMER", + observer_altitude=1200, + observer_latitude=35.6892 + ) + + from_day = 1 + to_day = 365 + total_irradiation = 0 + + # Loop over each day of the year + for day in range(from_day, to_day + 1): + # Find the optimal tilt angle for the given day + optimal_tilt_angle = calculator.find_optimal_orientation(day) + # Calculate the direct irradiation for the given day and add it to the total + total_irradiation += calculator.calculate_direct_irradiation( + optimal_tilt_angle, day + ) + + # Print the total direct irradiation received by the solar panel over the year + print(total_irradiation) Finding the Optimal Orientation ------------------------------- -The ``find_optimal_orientation`` method finds the optimal orientation for a solar + +The ``find_optimal_orientation`` method finds the optimal tilt angle for a solar panel given the climate type, observer altitude, observer latitude, and day of the year. The ``climate_type`` can be one of the following: @@ -32,9 +64,11 @@ The ``climate_type`` can be one of the following: from pysolorie import IrradiationCalculator - # Create an irradiation calculator for Tehran in the summer - irradiation_calculator = IrradiationCalculator( - "MIDLATITUDE SUMMER", 1200, 35.6892 + # Instantiate an IrradiationCalculator object for the city of Tehran + calculator = IrradiationCalculator( + climate_type="MIDLATITUDE SUMMER", + observer_altitude=1200, + observer_latitude=35.6892 ) # Find the optimal orientation for June 21st @@ -42,9 +76,11 @@ The ``climate_type`` can be one of the following: print(f"Optimal orientation: {result}") + + Calculating Direct Irradiation ------------------------------ -The ``calculate_direct_irradiation`` method calculates the total direct irradiation for a given solar panel orientation and day of the year. +The ``calculate_direct_irradiation`` method calculates the total direct irradiation received by a solar panel for a specified tilt angle and day of the year. The ``climate_type`` can be one of the following: @@ -57,9 +93,11 @@ The ``climate_type`` can be one of the following: from pysolorie import IrradiationCalculator - # Create an irradiation calculator for Tehran in the summer - irradiation_calculator = IrradiationCalculator( - "MIDLATITUDE SUMMER", 1200, 35.6892 + # Instantiate an IrradiationCalculator object for the city of Tehran + calculator = IrradiationCalculator( + climate_type="MIDLATITUDE SUMMER", + observer_altitude=1200, + observer_latitude=35.6892 ) # Define the panel orientation and day of the year @@ -71,88 +109,49 @@ The ``climate_type`` can be one of the following: print(f"Direct irradiation: {result}") +Plotting the Optimal Orientation +-------------------------------- -Generating a CSV Report ------------------------ - -The ``generate_optimal_orientation_csv_report`` method generates a CSV 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 CSV report for days 60 to 70 - report_generator.generate_optimal_orientation_csv_report(Path('results.csv'), irradiation_calculator, 60, 70) - -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. +With the pysolorie package, you can plot the optimal orientation of a solar panel given the climate type, altitude, and latitude of the location. For instance, the following code plots the optimal orientation for each day of the year for the city of Tehran. .. code-block:: python - from pysolorie import ReportGenerator, IrradiationCalculator - from pathlib import Path + from pathlib import Path + from pysolorie import IrradiationCalculator, Plotter - # Create a report generator and an irradiation calculator - report_generator = ReportGenerator() - irradiation_calculator = IrradiationCalculator("MIDLATITUDE SUMMER", 1200, 35.6892) + # Instantiate a Plotter object from the pysolorie library + plotter = Plotter() - # Generate an XML report for days 60 to 70 - report_generator.generate_optimal_orientation_xml_report(Path('results.xml'), irradiation_calculator, 60, 70) + # Instantiate an IrradiationCalculator object for the city of Tehran + irradiation_calculator = IrradiationCalculator( + climate_type="MIDLATITUDE SUMMER", + observer_altitude=1200, + observer_latitude=35.6892 + ) -The XML file will be saved to the specified path. + # Use the plotter to plot the optimal tilt angle of a solar panel for each day of the year + plotter.plot_optimal_orientation( + irradiation_calculator, + from_day=1, + to_day=365, + path=Path("results.svg"), + plot_kwargs={ + "xlabel": "Day", + "ylabel": "Beta (degrees)", + "title": "Optimal Solar Panel Orientation", + }, + savefig_kwargs={"dpi": 300}, + ) +This figure, generated by the example code, illustrates the optimal tilt angle of a solar panel for each day of the year in Tehran. The x-axis represents the day of the year, while the y-axis represents the optimal angle (Beta) in degrees. As can be seen, the optimal angle varies throughout the year, highlighting the importance of adjusting the tilt angle of the solar panel to maximize the energy received. +.. image:: _static/images/example_usage.svg + :width: 600 -Plotting the Optimal Orientation --------------------------------- The ``plot_optimal_orientation`` method plots the optimal orientation for a range of days. -.. code-block:: python - - from pysolorie import Plotter, IrradiationCalculator - from pathlib import Path - - # Create a plotter and an irradiation calculator - plotter = Plotter() - irradiation_calculator = IrradiationCalculator("MIDLATITUDE SUMMER", 1200, 35.6892) - - # Plot the optimal orientation for days 60 to 70 - plotter.plot_optimal_orientation(irradiation_calculator, 60, 70, Path('results.png'), plot_kwargs={'xlabel': 'Day', 'ylabel': 'Beta (degrees)', 'title': 'Optimal Solar Panel Orientation', "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. If the path is provided, the plot will be saved to the specified path and not displayed. If you want to both display and save the plot, you should call ``plt.show()`` after this function. @@ -167,38 +166,101 @@ The ``plot_total_direct_irradiation`` method plots the total direct irradiation from pysolorie import Plotter, IrradiationCalculator from pathlib import Path - # Create a plotter and an irradiation calculator + # Create a plotter plotter = Plotter() - irradiation_calculator = IrradiationCalculator("MIDLATITUDE SUMMER", 1200, 35.6892) + # Instantiate an IrradiationCalculator object for the city of Tehran + irradiation_calculator = IrradiationCalculator( + climate_type="MIDLATITUDE SUMMER", + observer_altitude=1200, + observer_latitude=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 (Megajoules per square meter)', 'title': 'Total Direct Irradiation', "figsize": (16,9)}, savefig_kwargs={'dpi': 300}) + plotter.plot_total_direct_irradiation( + irradiation_calculator, + from_day=60, + to_day=70, + path=Path("results.png"), + plot_kwargs={ + "xlabel": "Day", + "ylabel": "Total Direct Irradiation (Megajoules per square meter)", + "title": "Total Direct Irradiation", + }, + 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. If the path is provided, the plot will be saved to the specified path and not displayed. If you want to both display and save the plot, you should call ``plt.show()`` after this function. +Generating Reports +------------------ + +The ``pysolorie`` package provides methods to generate reports of the optimal orientation for a range of days in different formats: CSV, JSON, and XML. + +.. 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( + climate_type="MIDLATITUDE SUMMER", + observer_altitude=1200, + observer_latitude=35.6892, + ) + + # Generate a CSV report for days 60 to 70 + report_generator.generate_optimal_orientation_csv_report( + path=Path('results.csv'), + irradiation_calculator=irradiation_calculator, + from_day=60, + to_day=70, + ) + + # Generate a JSON report for days 60 to 70 + report_generator.generate_optimal_orientation_json_report( + path=Path('results.json'), + irradiation_calculator=irradiation_calculator, + from_day=60, + to_day=70, + ) + + # Generate an XML report for days 60 to 70 + report_generator.generate_optimal_orientation_xml_report( + path=Path('results.xml'), + irradiation_calculator=irradiation_calculator, + from_day=60, + to_day=70, + ) + +The report files will be saved to the specified paths. + + Calculating Sunrise and Sunset ------------------------------ -The ``calculate_sunrise_sunset`` method calculates the sunrise and sunset hour angles -for a given day of the year. +The ``calculate_sunrise_sunset`` method calculates the sunrise and sunset hour angles for a given day of the year. .. code-block:: python - from pysolorie import Observer + from pysolorie import Observer - # Create an observer located in Tehran - observer = Observer(observer_latitude=35.69) + # Create an observer located in Tehran + observer = Observer(observer_latitude=35.69) - # Calculate the sunrise and sunset hour angles for June 21st - sunrise_hour_angle, sunset_hour_angle = observer.calculate_sunrise_sunset(172) + # Calculate the sunrise and sunset hour angles for June 21st + sunrise_hour_angle, sunset_hour_angle = observer.calculate_sunrise_sunset( + day_of_year=172 + ) + + print(f"Sunrise hour angle: {sunrise_hour_angle}") + print(f"Sunset hour angle: {sunset_hour_angle}") - print(f"Sunrise hour angle: {sunrise_hour_angle}") - print(f"Sunset hour angle: {sunset_hour_angle}") Calculating the Solar Zenith Angle @@ -210,15 +272,18 @@ and solar time. .. code-block:: python - from pysolorie import Observer + from pysolorie import Observer + + # Create an observer located in Tehran (latitude 35.69, longitude 51.39) + observer = Observer(35.69, 51.39) + + # Calculate the zenith angle for March 22nd (81st day of the year) + # at solar noon (12 * 60 * 60 seconds) + zenith_angle = observer.calculate_zenith_angle(81, 12 * 60 * 60) - # Create an observer located in Tehran (latitude 35.69, longitude 51.39) - observer = Observer(35.69, 51.39) + print(f"Zenith angle: {zenith_angle}") - # Calculate the zenith angle for March 22nd (81st day of the year) at solar noon (12 * 60 * 60 seconds) - zenith_angle = observer.calculate_zenith_angle(81, 12 * 60 * 60) - print(f"Zenith angle: {zenith_angle}") Note that the observer's latitude must be provided when creating an ``Observer`` instance. If it's not provided, a ``ValueError`` will be raised: diff --git a/docs/index.rst b/docs/index.rst index 8932cf8..4128868 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -29,14 +29,15 @@ In our case, we are using Hottel's model to estimate the transmittance of direct ``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 tilt angle for a fixed solar panel, assuming a clear-sky condition. +- Plotting the optimal tilt angle over a range of days. +- Plotting the total direct irradiation over a range of days. +- Generating a CSV, JSON, or XML report detailing the optimal tilt angle over a range of days. +- Calculating the sunrise and sunset hour angles for a specific day. +- Utilizing Hottel's model to quantify clear-sky conditions and estimate the atmospheric transmission 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. References diff --git a/paper/paper.bib b/paper/paper.bib new file mode 100644 index 0000000..ba6b416 --- /dev/null +++ b/paper/paper.bib @@ -0,0 +1,66 @@ +@article{Aghamohammadi2023, + title = {Efficiency comparison between tracking and optimally fixed flat solar collectors}, + volume = {13}, + ISSN = {2045-2322}, + url = {http://dx.doi.org/10.1038/s41598-023-39892-y}, + DOI = {10.1038/s41598-023-39892-y}, + number = {1}, + journal = {Scientific Reports}, + publisher = {Springer Science and Business Media LLC}, + author = {Aghamohammadi, Amir and Foulaadvand, M. Ebrahim}, + year = {2023}, + month = aug +} + +@article{Hottel1976, + title = {A simple model for estimating the transmittance of direct solar radiation through clear atmospheres}, + volume = {18}, + ISSN = {0038-092X}, + url = {http://dx.doi.org/10.1016/0038-092X(76)90045-1}, + DOI = {10.1016/0038-092x(76)90045-1}, + number = {2}, + journal = {Solar Energy}, + publisher = {Elsevier BV}, + author = {Hottel, Hoyt C.}, + year = {1976}, + pages = {129–134} +} + +@article{Foulaadvand2023, + title = {Evaluation of direct beam energy received by convex solar collectors and their optimal orientations}, + volume = {15}, + ISSN = {1941-7012}, + url = {http://dx.doi.org/10.1063/5.0161277}, + DOI = {10.1063/5.0161277}, + number = {5}, + journal = {Journal of Renewable and Sustainable Energy}, + publisher = {AIP Publishing}, + author = {Foulaadvand, M. Ebrahim and Aghamohammadi, Amir}, + year = {2023}, + month = sep +} + +@book{DuffieDeceased2020, + title = {Solar Engineering of Thermal Processes, Photovoltaics and Wind}, + ISBN = {9781119540328}, + url = {http://dx.doi.org/10.1002/9781119540328}, + DOI = {10.1002/9781119540328}, + publisher = {Wiley}, + author = {Duffie (Deceased), John A. and Beckman, William A. and Blair, Nathan}, + year = {2020}, + month = feb +} + +@article{Astropy2022, +doi = {10.3847/1538-4357/ac7c74}, +url = {https://dx.doi.org/10.3847/1538-4357/ac7c74}, +year = {2022}, +month = {aug}, +publisher = {The American Astronomical Society}, +volume = {935}, +number = {2}, +pages = {167}, +author = {The Astropy Collaboration and Adrian M. Price-Whelan and Pey Lian Lim and Nicholas Earl and Nathaniel Starkman and Larry Bradley and David L. Shupe and Aarya A. Patil and Lia Corrales and C. E. Brasseur and Maximilian Nöthe and Axel Donath and Erik Tollerud and Brett M. Morris and Adam Ginsburg and Eero Vaher and Benjamin A. Weaver and James Tocknell and William Jamieson and Marten H. van Kerkwijk and Thomas P. Robitaille and Bruce Merry and Matteo Bachetti and H. Moritz Günther and Paper Authors and Thomas L. Aldcroft and Jaime A. Alvarado-Montes and Anne M. Archibald and Attila Bódi and Shreyas Bapat and Geert Barentsen and Juanjo Bazán and Manish Biswas and Médéric Boquien and D. J. Burke and Daria Cara and Mihai Cara and Kyle E Conroy and Simon Conseil and Matthew W. Craig and Robert M. Cross and Kelle L. Cruz and Francesco D’Eugenio and Nadia Dencheva and Hadrien A. R. Devillepoix and Jörg P. Dietrich and Arthur Davis Eigenbrot and Thomas Erben and Leonardo Ferreira and Daniel Foreman-Mackey and Ryan Fox and Nabil Freij and Suyog Garg and Robel Geda and Lauren Glattly and Yash Gondhalekar and Karl D. Gordon and David Grant and Perry Greenfield and Austen M. Groener and Steve Guest and Sebastian Gurovich and Rasmus Handberg and Akeem Hart and Zac Hatfield-Dodds and Derek Homeier and Griffin Hosseinzadeh and Tim Jenness and Craig K. Jones and Prajwel Joseph and J. Bryce Kalmbach and Emir Karamehmetoglu and Mikołaj Kałuszyński and Michael S. P. Kelley and Nicholas Kern and Wolfgang E. Kerzendorf and Eric W. Koch and Shankar Kulumani and Antony Lee and Chun Ly and Zhiyuan Ma and Conor MacBride and Jakob M. Maljaars and Demitri Muna and N. A. Murphy and Henrik Norman and Richard O’Steen and Kyle A. Oman and Camilla Pacifici and Sergio Pascual and J. Pascual-Granado and Rohit R. Patil and Gabriel I Perren and Timothy E. Pickering and Tanuj Rastogi and Benjamin R. Roulston and Daniel F Ryan and Eli S. Rykoff and Jose Sabater and Parikshit Sakurikar and Jesús Salgado and Aniket Sanghi and Nicholas Saunders and Volodymyr Savchenko and Ludwig Schwardt and Michael Seifert-Eckert and Albert Y. Shih and Anany Shrey Jain and Gyanendra Shukla and Jonathan Sick and Chris Simpson and Sudheesh Singanamalla and Leo P. Singer and Jaladh Singhal and Manodeep Sinha and Brigitta M. Sipőcz and Lee R. Spitler and David Stansby and Ole Streicher and Jani Šumak and John D. Swinbank and Dan S. Taranu and Nikita Tewary and Grant R. Tremblay and Miguel de Val-Borro and Samuel J. Van Kooten and Zlatan Vasović and Shresth Verma and José Vinícius de Miranda Cardoso and Peter K. G. Williams and Tom J. Wilson and Benjamin Winkel and W. M. Wood-Vasey and Rui Xue and Peter Yoachim and Chen Zhang and Andrea Zonca and Astropy Project Contributors}, +title = {The Astropy Project: Sustaining and Growing a Community-oriented Open-source Project and the Latest Major Release (v5.0) of the Core Package*}, +journal = {The Astrophysical Journal}, +} diff --git a/paper/paper.md b/paper/paper.md new file mode 100644 index 0000000..224969c --- /dev/null +++ b/paper/paper.md @@ -0,0 +1,75 @@ +--- +title: "pysolorie: a Python package for optimal orientation analysis of solar panels" +tags: + - Python + - astronomy + - solar energy + - solar panels +authors: + - name: Alireza Aghamohammadi + corresponding: true + affiliation: 1 + - name: Amir Aghamohammadi + affiliation: 2 + - given-names: M. Ebrahim + surname: Foulaadvand + affiliation: 3 +affiliations: + - name: Department of Computer Engineering, Sharif University of Technology, Tehran, Iran + index: 1 + - name: Department of Fundamental Physics, Faculty of Physics, Alzahra University, Tehran, Iran + index: 2 + - name: Department of Physics, University of Zanjan, Zanjan, Iran + index: 3 + +date: 21 December 2023 +bibliography: paper.bib +--- + +# Summary +How can one maximize the solar irradiation energy received by a fixed flat solar panel [@Aghamohammadi2023]? + +![Orientational angles of a flat solar panel.\label{fig:opt}](solarpanel.svg) + +**pysolorie** stands for **Py**thon **Sol**ar **Orie**ntation Analysis of Solar Panels. It is a Python package designed to help you find the optimal tilt angle of solar panels, $\beta$, to maximize the energy received for a given day. Its features include but are not limited to: + +- Finding the optimal tilt angle for a fixed solar panel, assuming a clear-sky condition. +- Plotting the optimal tilt angle over a range of days. +- Plotting the total direct irradiation over a range of days. +- Generating a CSV, JSON, or XML report detailing the optimal tilt angle over a range of days. +- Utilizing Hottel's model to quantify clear-sky conditions and estimate the atmospheric transmission of clear-sky beam radiation [@Hottel1976]. + + +# Statement of need +The amount of solar irradiation energy harvested by a solar collector depends on several factors. These include the time of irradiation (both the time of day and the day of the year), the latitude and climate of the location, and the shape and orientation of the solar panel [@Foulaadvand2023]. +A solar collector can be positioned at a fixed orientation to maximize energy reception for a specific time period, such as daily, weekly, monthly, or seasonally, or it can be fixed for optimal performance throughout the year. The orientation can then be adjusted for the next time period. +Solar irradiation is composed of three components: the direct beam, sky diffusion, and ground reflection. For flat solar panels, we focus on direct beam irradiation, which contributes the most to solar irradiation. There are various models available for different climate types. We assume a clear sky condition with no clouds in the sky and an atmosphere free of pollutants. However, a cloudy sky or polluted air may affect the amount of solar energy received on Earth. In this context, we focus on the overall received irradiation energy. There are some other issues which may require careful consideration and further investigation in future studies, such as panel efficiency, energy conversion, and the effects of air pollution. + In our case, we use Hottel’s model to estimate the transmittance of direct solar radiation through a clear and sunny atmosphere [@Hottel1976]. + + With a well-defined, user-friendly, and extensible API, multiple audiences can benefit from `pysolorie`. These include solar energy researchers, solar panel manufacturers and installers, and instructors. + +`Astropy` is the most well-known Python package with comprehensive functionality for astronomy and astrophysics [@Astropy2022]. It provides astronomical coordinate systems, cosmological calculations, and many more features. However, it lacks the ability to determine the optimal orientation of a flat solar panel, a feature that `pysolarorie` provides. + + + +# Background +The energy collected by a solar panel can be calculated using the formula in Equation \ref{eq:totaldirect}, where: + +- $n$ is the day of the year, +- $\phi$ is the latitude of the observer, +- $I(n)$ is the amount of solar energy received per unit area per second [@DuffieDeceased2020] on day number $n$ of the year, +- $\Omega$ is the Earth angular velocity around its axis with the value `7.15 * 1e-5` rad/s, +- $\theta$ is the angle between the position vector of the sun and the normal vector to the solar panel (incidence angle), +- $\omega_s$ is the sunrise hour angle, +- $\omega_t$ is the sunset hour angle, +- $H$ is the Heaviside step function, +- $\tau_b$ is the beam atmospheric transmittance [@Hottel1976]. + +\begin{equation}\label{eq:totaldirect} +E(n,\phi) = \frac{I(n)}{\Omega} \int_{\omega_s}^{\omega_t}\cos(\theta) \times H(\cos(\theta)) \times \tau_b~d\omega +\end{equation} + + + + +# References diff --git a/paper/solarpanel.svg b/paper/solarpanel.svg new file mode 100644 index 0000000..38baa42 --- /dev/null +++ b/paper/solarpanel.svg @@ -0,0 +1,524 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NSEW + + Zenith + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/setup.cfg b/setup.cfg index 1a99ffb..2d532a9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = pysolorie -version = 1.5.1 +version = 1.5.2 description = Orientation Analysis of Solar Panel long_description = file: README.md long_description_content_type = text/markdown @@ -102,5 +102,6 @@ commands = deps = sphinx sphinx-rtd-theme + sphinx-copybutton commands = sphinx-build -W -b html docs/ docs/_build/ diff --git a/src/pysolorie/__init__.py b/src/pysolorie/__init__.py index 9f81424..04d2238 100644 --- a/src/pysolorie/__init__.py +++ b/src/pysolorie/__init__.py @@ -13,7 +13,6 @@ # limitations under the License. from .atmospheric_transmission import AtmosphericTransmission -from .exceptions import InvalidClimateTypeError from .irradiance import SolarIrradiance from .model import HottelModel from .numerical_integration import IrradiationCalculator @@ -25,7 +24,6 @@ __all__ = [ "AtmosphericTransmission", "HottelModel", - "InvalidClimateTypeError", "IrradiationCalculator", "Observer", "Plotter", diff --git a/src/pysolorie/irradiance.py b/src/pysolorie/irradiance.py index 5191313..fa108d2 100644 --- a/src/pysolorie/irradiance.py +++ b/src/pysolorie/irradiance.py @@ -45,7 +45,7 @@ def calculate_extraterrestrial_irradiance(self, day_of_year: int) -> float: The formula used is: .. math:: - I = SC + I(n) = SC \times (1 + 0.033 \times \cos (\frac{2\pi~n}{365})) diff --git a/src/pysolorie/logger.py b/src/pysolorie/logger.py index 6c6d431..76e8f54 100644 --- a/src/pysolorie/logger.py +++ b/src/pysolorie/logger.py @@ -24,11 +24,11 @@ def logger_decorator(func): @functools.wraps(func) def wrapper(self, *args, **kwargs): - logger = logging.getLogger(func.__name__) - logger.setLevel(logging.INFO) - logger.info(f"Running '{func.__name__}'") + self.logger = logging.getLogger(func.__name__) + self.logger.setLevel(logging.INFO) + self.logger.info(f"Running '{func.__name__}'") result = func(self, *args, **kwargs) - logger.info(f"Finished '{func.__name__}'") + self.logger.info(f"Finished '{func.__name__}'") return result return wrapper diff --git a/src/pysolorie/plotter.py b/src/pysolorie/plotter.py index e9c9f5d..14d82f3 100644 --- a/src/pysolorie/plotter.py +++ b/src/pysolorie/plotter.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import logging from pathlib import Path from typing import Dict, List, Optional, Tuple @@ -109,6 +108,7 @@ def plot_total_direct_irradiation( self._plot(days, total_direct_irradiations, path, plot_kwargs, savefig_kwargs) + @logger_decorator def _calculate_optimal_orientations( self, irradiation_calculator: IrradiationCalculator, from_day: int, to_day: int ) -> Tuple[List[int], List[float]]: @@ -117,8 +117,7 @@ def _calculate_optimal_orientations( for day in range(from_day, to_day): beta = irradiation_calculator.find_optimal_orientation(day) - logger = logging.getLogger(self._calculate_optimal_orientations.__name__) - logger.info( + self.logger.info( # type: ignore f"On day {day}," + f"the solar panel's optimal orientation is {beta} degrees." ) diff --git a/src/pysolorie/report.py b/src/pysolorie/report.py index 03f0fca..87055aa 100644 --- a/src/pysolorie/report.py +++ b/src/pysolorie/report.py @@ -14,7 +14,6 @@ import csv import json -import logging import xml.etree.ElementTree as ET from pathlib import Path from typing import Dict, List, Union @@ -29,6 +28,7 @@ class ReportGenerator: of solar panels and the total direct irradiation. """ + @logger_decorator def _calculate_optimal_orientation_and_irradiation( self, irradiation_calculator: IrradiationCalculator, @@ -57,10 +57,8 @@ def _calculate_optimal_orientation_and_irradiation( total_direct_irradiation = ( irradiation_calculator.calculate_direct_irradiation(beta, day) ) - logger = logging.getLogger( - self._calculate_optimal_orientation_and_irradiation.__name__ - ) - logger.info( + + self.logger.info( # type: ignore 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." diff --git a/tests/test_atmospheric_transmission.py b/tests/test_atmospheric_transmission.py new file mode 100644 index 0000000..05a4c36 --- /dev/null +++ b/tests/test_atmospheric_transmission.py @@ -0,0 +1,48 @@ +# Copyright 2023 Alireza Aghamohammadi + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from pysolorie import AtmosphericTransmission + + +@pytest.mark.parametrize( + "climate_type," + + "observer_altitude," + + "observer_latitude," + + "day_of_year," + + "solar_time," + + "expected_transmittance", + [ + ("MIDLATITUDE SUMMER", 1200, 35.69, 81, 12 * 60 * 60, 0.683), # Tehran Summer + ("MIDLATITUDE WINTER", 1200, 35.69, 355, 12 * 60 * 60, 0.618), # Tehran Winter + ("TROPICAL", 63, 3.59, 81, 10 * 60 * 60, 0.597), # Medan + ("SUBARCTIC SUMMER", 136, 64.84, 1, 13 * 60 * 60, 0.140), # Fairbanks + ], +) +def test_calculate_transmittance( + climate_type: str, + observer_altitude: int, + observer_latitude: float, + day_of_year: int, + solar_time: float, + expected_transmittance: float, +) -> None: + atmospheric_transmission: AtmosphericTransmission = AtmosphericTransmission( + climate_type, observer_altitude, observer_latitude + ) + result: float = atmospheric_transmission.calculate_transmittance( + day_of_year, solar_time + ) + assert pytest.approx(result, abs=1e-3) == expected_transmittance diff --git a/tests/test_irradiance.py b/tests/test_irradiance.py new file mode 100644 index 0000000..410e302 --- /dev/null +++ b/tests/test_irradiance.py @@ -0,0 +1,38 @@ +# Copyright 2023 Alireza Aghamohammadi + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from pysolorie import SolarIrradiance, SunPosition + + +@pytest.mark.parametrize( + "day_of_year, expected_irradiance", + [ + (1, 0.001411444), # January 1st + (81, 0.001374918), # March 22nd (equinox) + (172, 0.00132262), # June 21st (summer solstice) + (264, 0.001359464), # September 23rd (equinox) + (355, 0.001412104), # December 21st (winter solstice) + ], +) +def test_calculate_extraterrestrial_irradiance( + day_of_year: int, expected_irradiance: float +) -> None: + sun_position = SunPosition() + solar_irradiance = SolarIrradiance(sun_position) + irradiance: float = solar_irradiance.calculate_extraterrestrial_irradiance( + day_of_year + ) + assert irradiance == pytest.approx(expected_irradiance, abs=1e-5) diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..9995ac8 --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,52 @@ +# Copyright 2023 Alireza Aghamohammadi + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Tuple + +import pytest + +from pysolorie import HottelModel, exceptions + + +@pytest.mark.parametrize( + "climate_type, observer_altitude, expected_result", + [ + ("MIDLATITUDE SUMMER", 1200, (0.228, 0.666, 0.309)), # Tehran Summer + ("MIDLATITUDE WINTER", 1200, (0.242, 0.679, 0.303)), # Tehran Winter + ("TROPICAL", 26, (0.124, 0.739, 0.392)), # Medan + ("SUBARCTIC SUMMER", 136, (0.140, 0.739, 0.379)), # Fairbanks + ], +) +def test_calculate_transmittance_components( + climate_type: str, + observer_altitude: int, + expected_result: Tuple[float, float, float], +) -> None: + hottel_model: HottelModel = HottelModel() + result: Tuple[ + float, float, float + ] = hottel_model.calculate_transmittance_components(climate_type, observer_altitude) + assert pytest.approx(result, abs=1e-3) == expected_result + + +def test_invalid_climate_type() -> None: + with pytest.raises(ValueError, match="Invalid climate type"): + hottel_model: HottelModel = HottelModel() + hottel_model.calculate_transmittance_components("INVALID", 1000) + + +def test_invalid_climate_type_with_custom_message() -> None: + custom_message = "Custom error message" + with pytest.raises(exceptions.InvalidClimateTypeError, match=custom_message): + raise exceptions.InvalidClimateTypeError("INVALID", custom_message) diff --git a/tests/test_numerical_integration.py b/tests/test_numerical_integration.py new file mode 100644 index 0000000..d64d6e7 --- /dev/null +++ b/tests/test_numerical_integration.py @@ -0,0 +1,123 @@ +# Copyright 2023 Alireza Aghamohammadi + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from pysolorie import IrradiationCalculator + + +@pytest.mark.parametrize( + "climate_type," + + "observer_altitude," + + "observer_latitude," + + "day_of_year," + + "panel_orientation," + + "expected_result", + [ + ( + "MIDLATITUDE SUMMER", + 1200, + 35.6892, + 172, + 45.0, + 20.3026, # Tehran Summer, day_of_year=172 (June 21) + ), + ( + "MIDLATITUDE WINTER", + 1200, + 35.6892, + 355, + 45.0, + 19.436, # Tehran Winter, day_of_year=355 (Dec 21) + ), + ( + "TROPICAL", + 26, + 3.5952, + 100, + 45.0, + 13.224, # Medan, day_of_year=100 (April 10) + ), + ( + "SUBARCTIC SUMMER", + 132, + 64.84361, + 200, + 45.0, + 21.371, # Fairbanks Summer, day_of_year=200 (July 19) + ), + ], +) +def test_calculate_direct_irradiation( + climate_type: str, + observer_altitude: int, + observer_latitude: float, + day_of_year: int, + panel_orientation: float, + expected_result: float, +) -> None: + irradiation_calculator = IrradiationCalculator( + climate_type, observer_altitude, observer_latitude + ) + result = irradiation_calculator.calculate_direct_irradiation( + panel_orientation, day_of_year + ) + assert pytest.approx(result, abs=1e-3) == expected_result + + +@pytest.mark.parametrize( + "climate_type, observer_altitude, observer_latitude, day_of_year, expected_result", + [ + ( + "MIDLATITUDE SUMMER", + 1200, + 35.6892, + 172, + 0.170, + ), # Tehran Summer, day_of_year=172 (June 21) + ( + "MIDLATITUDE WINTER", + 1200, + 35.6892, + 355, + 63.791, + ), # Tehran Winter, day_of_year=355 (Dec 21) + ( + "TROPICAL", + 26, + 3.5952, + 100, + -6.610, + ), # Medan, day_of_year=100 (April 10) + ( + "SUBARCTIC SUMMER", + 132, + 64.84361, + 200, + 32.614, + ), # Fairbanks Summer, day_of_year=200 (July 19) + ], +) +def test_find_optimal_orientation( + climate_type: str, + observer_altitude: int, + observer_latitude: float, + day_of_year: int, + expected_result: float, +) -> None: + irradiation_calculator = IrradiationCalculator( + climate_type, observer_altitude, observer_latitude + ) + result = irradiation_calculator.find_optimal_orientation(day_of_year) + assert pytest.approx(result, abs=1e-3) == expected_result diff --git a/tests/test_observer.py b/tests/test_observer.py new file mode 100644 index 0000000..5842f63 --- /dev/null +++ b/tests/test_observer.py @@ -0,0 +1,109 @@ +# Copyright 2023 Alireza Aghamohammadi + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import math + +import pytest + +from pysolorie import Observer + + +@pytest.mark.parametrize( + "observer_latitude," + + "observer_longitude," + + "day_of_year," + + "solar_time," + + "expected_zenith_angle", + [ + ( + 35.69, + 51.39, + 81, + 12 * 60 * 60, + 0.623, + ), # Test at Tehran on the 81st day of the year at noon + ( + 35.69, + 51.39, + 355, + 12 * 60 * 60, + 1.032, + ), # Test at Tehran on the 355th day of the year at noon + ( + 3.59, + 98.67, + 81, + 10 * 60 * 60, + 0.527, + ), # Test at Medan on the 81st day of the year at 10am + ( + 64.84, + -147.72, + 1, + 13 * 60 * 60, + 1.547, + ), # Test at Fairbanks on the 1st day of the year at 1pm + (90, 0, 1, 13 * 60 * 60, 1.972), # Test at North Pole on January 1st at 1pm + ], +) +def test_calculate_zenith_angle( + observer_latitude: float, + observer_longitude: float, + day_of_year: int, + solar_time: int, + expected_zenith_angle: float, +) -> None: + observer: Observer = Observer(observer_latitude, observer_longitude) + expected_observer_latitude = math.radians(observer_latitude) + expected_observer_longitude = math.radians(observer_longitude) + + assert observer.observer_latitude == pytest.approx( + expected_observer_latitude, abs=1e-3 + ) + assert observer.observer_longitude == pytest.approx( + expected_observer_longitude, abs=1e-3 + ) + + zenith_angle: float = observer.calculate_zenith_angle(day_of_year, solar_time) + assert zenith_angle == pytest.approx(expected_zenith_angle, abs=1e-3) + + +def test_calculate_zenith_angle_without_latitude(): + observer = Observer(None, 0) + with pytest.raises( + ValueError, + match="Missing required data: Observer latitude", + ): + observer.calculate_zenith_angle(1, 12 * 60 * 60) + + +@pytest.mark.parametrize( + "day_of_year, expected_sunrise_hour_angle, expected_sunset_hour_angle", + [ + (1, -1.261, 1.261), # January 1st + (81, -math.pi / 2, math.pi / 2), # March 22nd (equinox) + (172, -1.888, 1.888), # June 21st (solstice) + ], +) +def test_calculate_sunrise_sunset( + day_of_year: int, + expected_sunrise_hour_angle: float, + expected_sunset_hour_angle: float, +) -> None: + observer: Observer = Observer(observer_latitude=35.69) # Tehran + sunrise_hour_angle, sunset_hour_angle = observer.calculate_sunrise_sunset( + day_of_year + ) + assert sunrise_hour_angle == pytest.approx(expected_sunrise_hour_angle, abs=1e-3) + assert sunset_hour_angle == pytest.approx(expected_sunset_hour_angle, abs=1e-3) diff --git a/tests/test_plotter.py b/tests/test_plotter.py new file mode 100644 index 0000000..1c45af7 --- /dev/null +++ b/tests/test_plotter.py @@ -0,0 +1,114 @@ +# Copyright 2023 Alireza Aghamohammadi + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +from pathlib import Path +from typing import Any, Dict, List +from unittest.mock import MagicMock + +import matplotlib.pyplot as plt +import numpy as np + +from pysolorie import IrradiationCalculator, Plotter + + +def test_plot_optimal_orientation(caplog, tmpdir) -> None: + caplog.set_level(logging.INFO) + + # Create a temporary directory for the test + temp_dir: Path = Path(tmpdir) + + # Initialize the Plotter + plotter: Plotter = Plotter() + + # Initialize the IrradiationCalculator for Tehran + irradiation_calculator: IrradiationCalculator = IrradiationCalculator( + "MIDLATITUDE SUMMER", 1200, 35.6892 + ) + + # Define the path for the plot + plot_path: Path = temp_dir / "plot.png" + from_day: int = 60 + to_day: int = 70 + # Call the method to generate the plot + plotter.plot_optimal_orientation( + irradiation_calculator, from_day, to_day, plot_path + ) + + # Check the plot file + assert plot_path.exists(), "The plot file was not created." + + img: np.ndarray = plt.imread(plot_path) + assert img.shape[0] > 0, "The plot image has no content." + assert img.shape[1] > 0, "The plot image has no content." + + # Check the logs + for day in range(from_day, to_day): + assert any( + f"On day {day}," in record.message for record in caplog.records + ), f"No log message for day {day}" + + +def test_plot_total_direct_irradiation(caplog, tmpdir) -> None: + caplog.set_level(logging.INFO) + + # Create a temporary directory for the test + temp_dir: Path = Path(tmpdir) + + # Initialize the Plotter + plotter: Plotter = Plotter() + + # Initialize the IrradiationCalculator for Tehran + irradiation_calculator: IrradiationCalculator = IrradiationCalculator( + "MIDLATITUDE SUMMER", 1200, 35.6892 + ) + + # Define the path for the plot + plot_path: Path = temp_dir / "plot.png" + from_day: int = 60 + to_day: int = 70 + # Call the method to generate the plot + plotter.plot_total_direct_irradiation( + irradiation_calculator, from_day, to_day, plot_path + ) + + # Check the plot file + assert plot_path.exists(), "The plot file was not created." + + img: np.ndarray = plt.imread(plot_path) + assert img.shape[0] > 0, "The plot image has no content." + assert img.shape[1] > 0, "The plot image has no content." + + # Check the logs + for day in range(from_day, to_day): + assert any( + f"On day {day}," in record.message for record in caplog.records + ), f"No log message for day {day}" + + +def test_plot_method() -> None: + # Set up the necessary variables + plotter: Plotter = Plotter() + days: List[int] = [1, 2, 3] + betas: List[float] = [10.0, 20.0, 30.0] + path: None = None + plot_kwargs: Dict[str, Any] = {} + savefig_kwargs: Dict[str, Any] = {} + + # Replace plt.show with a mock + plt.show = MagicMock() + + # Call the method + plotter._plot(days, betas, path, plot_kwargs, savefig_kwargs) + plt.show.assert_called_once() diff --git a/tests/test_pysolorie.py b/tests/test_pysolorie.py deleted file mode 100644 index d989ab1..0000000 --- a/tests/test_pysolorie.py +++ /dev/null @@ -1,607 +0,0 @@ -# Copyright 2023 Alireza Aghamohammadi - -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at - -# http://www.apache.org/licenses/LICENSE-2.0 - -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import csv -import json -import logging -import math -import xml.etree.ElementTree as ET -from pathlib import Path -from typing import Any, Dict, List, Tuple -from unittest.mock import MagicMock - -import matplotlib.pyplot as plt -import numpy as np -import pytest - -from pysolorie import ( - AtmosphericTransmission, - HottelModel, - InvalidClimateTypeError, - IrradiationCalculator, - Observer, - Plotter, - ReportGenerator, - SolarIrradiance, - SunPosition, -) - - -@pytest.mark.parametrize( - "climate_type, observer_altitude, expected_result", - [ - ("MIDLATITUDE SUMMER", 1200, (0.228, 0.666, 0.309)), # Tehran Summer - ("MIDLATITUDE WINTER", 1200, (0.242, 0.679, 0.303)), # Tehran Winter - ("TROPICAL", 26, (0.124, 0.739, 0.392)), # Medan - ("SUBARCTIC SUMMER", 136, (0.140, 0.739, 0.379)), # Fairbanks - ], -) -def test_calculate_transmittance_components( - climate_type: str, - observer_altitude: int, - expected_result: Tuple[float, float, float], -) -> None: - hottel_model: HottelModel = HottelModel() - result: Tuple[ - float, float, float - ] = hottel_model.calculate_transmittance_components(climate_type, observer_altitude) - assert pytest.approx(result, abs=1e-3) == expected_result - - -def test_invalid_climate_type() -> None: - with pytest.raises(ValueError, match="Invalid climate type"): - hottel_model: HottelModel = HottelModel() - hottel_model.calculate_transmittance_components("INVALID", 1000) - - -def test_invalid_climate_type_with_custom_message() -> None: - custom_message = "Custom error message" - with pytest.raises(InvalidClimateTypeError, match=custom_message): - raise InvalidClimateTypeError("INVALID", custom_message) - - -@pytest.mark.parametrize( - "day_of_year, solar_time, expected_declination, expected_hour_angle", - [ - (1, 12 * 60 * 60, -0.4014257279586958, 0), # January 1st at noon - (81, 10 * 60 * 60, 0, -math.pi / 6), # March 22nd at 10am (equinox) - (81, 12 * 60 * 60, 0, 0), # March 22nd at noon (equinox) - (1, 13 * 60 * 60, -0.4014257279586958, math.pi / 12), # January 1st at 1pm - ], -) -def test_sun_position( - day_of_year: int, - solar_time: int, - expected_declination: float, - expected_hour_angle: float, -) -> None: - sun_position: SunPosition = SunPosition() - declination: float = sun_position.solar_declination(day_of_year) - hour_angle: float = sun_position.hour_angle(solar_time) - assert declination == pytest.approx(expected_declination, abs=1e-3) - assert hour_angle == pytest.approx(expected_hour_angle, abs=1e-3) - - -@pytest.mark.parametrize( - "hour_angle, expected_solar_time", - [ - (0, 12 * 60 * 60), # solar noon - (-math.pi / 6, 10 * 60 * 60), # 10am - (math.pi / 12, 13 * 60 * 60), # 1pm - (math.pi, 24 * 60 * 60), # solar night - ], -) -def test_solar_time( - hour_angle: float, - expected_solar_time: int, -) -> None: - sun_position: SunPosition = SunPosition() - solar_time: float = sun_position.solar_time(hour_angle) - assert solar_time == pytest.approx(expected_solar_time, abs=1e-3) - - -@pytest.mark.parametrize( - "day_of_year, expected_irradiance", - [ - (1, 0.001411444), # January 1st - (81, 0.001374918), # March 22nd (equinox) - (172, 0.00132262), # June 21st (summer solstice) - (264, 0.001359464), # September 23rd (equinox) - (355, 0.001412104), # December 21st (winter solstice) - ], -) -def test_calculate_extraterrestrial_irradiance( - day_of_year: int, expected_irradiance: float -) -> None: - sun_position = SunPosition() - solar_irradiance = SolarIrradiance(sun_position) - irradiance: float = solar_irradiance.calculate_extraterrestrial_irradiance( - day_of_year - ) - assert irradiance == pytest.approx(expected_irradiance, abs=1e-5) - - -@pytest.mark.parametrize( - "observer_latitude," - + "observer_longitude," - + "day_of_year," - + "solar_time," - + "expected_zenith_angle", - [ - ( - 35.69, - 51.39, - 81, - 12 * 60 * 60, - 0.623, - ), # Test at Tehran on the 81st day of the year at noon - ( - 35.69, - 51.39, - 355, - 12 * 60 * 60, - 1.032, - ), # Test at Tehran on the 355th day of the year at noon - ( - 3.59, - 98.67, - 81, - 10 * 60 * 60, - 0.527, - ), # Test at Medan on the 81st day of the year at 10am - ( - 64.84, - -147.72, - 1, - 13 * 60 * 60, - 1.547, - ), # Test at Fairbanks on the 1st day of the year at 1pm - (90, 0, 1, 13 * 60 * 60, 1.972), # Test at North Pole on January 1st at 1pm - ], -) -def test_calculate_zenith_angle( - observer_latitude: float, - observer_longitude: float, - day_of_year: int, - solar_time: int, - expected_zenith_angle: float, -) -> None: - observer: Observer = Observer(observer_latitude, observer_longitude) - expected_observer_latitude = math.radians(observer_latitude) - expected_observer_longitude = math.radians(observer_longitude) - - assert observer.observer_latitude == pytest.approx( - expected_observer_latitude, abs=1e-3 - ) - assert observer.observer_longitude == pytest.approx( - expected_observer_longitude, abs=1e-3 - ) - - zenith_angle: float = observer.calculate_zenith_angle(day_of_year, solar_time) - assert zenith_angle == pytest.approx(expected_zenith_angle, abs=1e-3) - - -def test_calculate_zenith_angle_without_latitude(): - observer = Observer(None, 0) - with pytest.raises( - ValueError, - match="Missing required data: Observer latitude", - ): - observer.calculate_zenith_angle(1, 12 * 60 * 60) - - -@pytest.mark.parametrize( - "climate_type," - + "observer_altitude," - + "observer_latitude," - + "day_of_year," - + "solar_time," - + "expected_transmittance", - [ - ("MIDLATITUDE SUMMER", 1200, 35.69, 81, 12 * 60 * 60, 0.683), # Tehran Summer - ("MIDLATITUDE WINTER", 1200, 35.69, 355, 12 * 60 * 60, 0.618), # Tehran Winter - ("TROPICAL", 63, 3.59, 81, 10 * 60 * 60, 0.597), # Medan - ("SUBARCTIC SUMMER", 136, 64.84, 1, 13 * 60 * 60, 0.140), # Fairbanks - ], -) -def test_calculate_transmittance( - climate_type: str, - observer_altitude: int, - observer_latitude: float, - day_of_year: int, - solar_time: float, - expected_transmittance: float, -) -> None: - atmospheric_transmission: AtmosphericTransmission = AtmosphericTransmission( - climate_type, observer_altitude, observer_latitude - ) - result: float = atmospheric_transmission.calculate_transmittance( - day_of_year, solar_time - ) - assert pytest.approx(result, abs=1e-3) == expected_transmittance - - -@pytest.mark.parametrize( - "day_of_year, expected_sunrise_hour_angle, expected_sunset_hour_angle", - [ - (1, -1.261, 1.261), # January 1st - (81, -math.pi / 2, math.pi / 2), # March 22nd (equinox) - (172, -1.888, 1.888), # June 21st (solstice) - ], -) -def test_calculate_sunrise_sunset( - day_of_year: int, - expected_sunrise_hour_angle: float, - expected_sunset_hour_angle: float, -) -> None: - observer: Observer = Observer(observer_latitude=35.69) # Tehran - sunrise_hour_angle, sunset_hour_angle = observer.calculate_sunrise_sunset( - day_of_year - ) - assert sunrise_hour_angle == pytest.approx(expected_sunrise_hour_angle, abs=1e-3) - assert sunset_hour_angle == pytest.approx(expected_sunset_hour_angle, abs=1e-3) - - -@pytest.mark.parametrize( - "climate_type," - + "observer_altitude," - + "observer_latitude," - + "day_of_year," - + "panel_orientation," - + "expected_result", - [ - ( - "MIDLATITUDE SUMMER", - 1200, - 35.6892, - 172, - 45.0, - 20.3026, # Tehran Summer, day_of_year=172 (June 21) - ), - ( - "MIDLATITUDE WINTER", - 1200, - 35.6892, - 355, - 45.0, - 19.436, # Tehran Winter, day_of_year=355 (Dec 21) - ), - ( - "TROPICAL", - 26, - 3.5952, - 100, - 45.0, - 13.224, # Medan, day_of_year=100 (April 10) - ), - ( - "SUBARCTIC SUMMER", - 132, - 64.84361, - 200, - 45.0, - 21.371, # Fairbanks Summer, day_of_year=200 (July 19) - ), - ], -) -def test_calculate_direct_irradiation( - climate_type: str, - observer_altitude: int, - observer_latitude: float, - day_of_year: int, - panel_orientation: float, - expected_result: float, -) -> None: - irradiation_calculator = IrradiationCalculator( - climate_type, observer_altitude, observer_latitude - ) - result = irradiation_calculator.calculate_direct_irradiation( - panel_orientation, day_of_year - ) - assert pytest.approx(result, abs=1e-3) == expected_result - - -@pytest.mark.parametrize( - "climate_type, observer_altitude, observer_latitude, day_of_year, expected_result", - [ - ( - "MIDLATITUDE SUMMER", - 1200, - 35.6892, - 172, - 0.170, - ), # Tehran Summer, day_of_year=172 (June 21) - ( - "MIDLATITUDE WINTER", - 1200, - 35.6892, - 355, - 63.791, - ), # Tehran Winter, day_of_year=355 (Dec 21) - ( - "TROPICAL", - 26, - 3.5952, - 100, - -6.610, - ), # Medan, day_of_year=100 (April 10) - ( - "SUBARCTIC SUMMER", - 132, - 64.84361, - 200, - 32.614, - ), # Fairbanks Summer, day_of_year=200 (July 19) - ], -) -def test_find_optimal_orientation( - climate_type: str, - observer_altitude: int, - observer_latitude: float, - day_of_year: int, - expected_result: float, -) -> None: - irradiation_calculator = IrradiationCalculator( - climate_type, observer_altitude, observer_latitude - ) - result = irradiation_calculator.find_optimal_orientation(day_of_year) - assert pytest.approx(result, abs=1e-3) == expected_result - - -def test_generate_optimal_orientation_csv_report(caplog, tmpdir) -> None: - caplog.set_level(logging.INFO) - - # 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 CSV file - csv_path: Path = temp_dir / "report.csv" - from_day: int = 60 - to_day: int = 70 - # Call the method to generate the report - report_generator.generate_optimal_orientation_csv_report( - csv_path, irradiation_calculator, from_day, to_day - ) - - # Check the CSV file - with open(csv_path, "r") as file: - reader = csv.reader(file) - header = next(reader) - 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]), - float(row[1]), - float(row[2]), - ) - - 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 - ) - # Check the logs - for day in range(from_day, to_day): - assert any( - f"On day {day}," in record.message for record in caplog.records - ), f"No log message for day {day}" - - -def test_generate_optimal_orientation_json_report(caplog, tmpdir) -> None: - caplog.set_level(logging.INFO) - - # 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 - ) - # Check the logs - for day in range(from_day, to_day): - assert any( - f"On day {day}," in record.message for record in caplog.records - ), f"No log message for day {day}" - - -def test_generate_optimal_orientation_xml_report(caplog, tmpdir) -> None: - caplog.set_level(logging.INFO) - - # 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 - ) - # Check the logs - for day in range(from_day, to_day): - assert any( - f"On day {day}," in record.message for record in caplog.records - ), f"No log message for day {day}" - - -def test_plot_optimal_orientation(caplog, tmpdir) -> None: - caplog.set_level(logging.INFO) - - # Create a temporary directory for the test - temp_dir: Path = Path(tmpdir) - - # Initialize the Plotter - plotter: Plotter = Plotter() - - # Initialize the IrradiationCalculator for Tehran - irradiation_calculator: IrradiationCalculator = IrradiationCalculator( - "MIDLATITUDE SUMMER", 1200, 35.6892 - ) - - # Define the path for the plot - plot_path: Path = temp_dir / "plot.png" - from_day: int = 60 - to_day: int = 70 - # Call the method to generate the plot - plotter.plot_optimal_orientation( - irradiation_calculator, from_day, to_day, plot_path - ) - - # Check the plot file - assert plot_path.exists(), "The plot file was not created." - - img: np.ndarray = plt.imread(plot_path) - assert img.shape[0] > 0, "The plot image has no content." - assert img.shape[1] > 0, "The plot image has no content." - - # Check the logs - for day in range(from_day, to_day): - assert any( - f"On day {day}," in record.message for record in caplog.records - ), f"No log message for day {day}" - - -def test_plot_total_direct_irradiation(caplog, tmpdir) -> None: - caplog.set_level(logging.INFO) - - # Create a temporary directory for the test - temp_dir: Path = Path(tmpdir) - - # Initialize the Plotter - plotter: Plotter = Plotter() - - # Initialize the IrradiationCalculator for Tehran - irradiation_calculator: IrradiationCalculator = IrradiationCalculator( - "MIDLATITUDE SUMMER", 1200, 35.6892 - ) - - # Define the path for the plot - plot_path: Path = temp_dir / "plot.png" - from_day: int = 60 - to_day: int = 70 - # Call the method to generate the plot - plotter.plot_total_direct_irradiation( - irradiation_calculator, from_day, to_day, plot_path - ) - - # Check the plot file - assert plot_path.exists(), "The plot file was not created." - - img: np.ndarray = plt.imread(plot_path) - assert img.shape[0] > 0, "The plot image has no content." - assert img.shape[1] > 0, "The plot image has no content." - - # Check the logs - for day in range(from_day, to_day): - assert any( - f"On day {day}," in record.message for record in caplog.records - ), f"No log message for day {day}" - - -def test_plot_method() -> None: - # Set up the necessary variables - plotter: Plotter = Plotter() - days: List[int] = [1, 2, 3] - betas: List[float] = [10.0, 20.0, 30.0] - path: None = None - plot_kwargs: Dict[str, Any] = {} - savefig_kwargs: Dict[str, Any] = {} - - # Replace plt.show with a mock - plt.show = MagicMock() - - # Call the method - plotter._plot(days, betas, path, plot_kwargs, savefig_kwargs) - plt.show.assert_called_once() diff --git a/tests/test_report.py b/tests/test_report.py new file mode 100644 index 0000000..2549979 --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,181 @@ +# Copyright 2023 Alireza Aghamohammadi + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import csv +import json +import logging +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + +from pysolorie import IrradiationCalculator, ReportGenerator + + +def test_generate_optimal_orientation_csv_report(caplog, tmpdir) -> None: + caplog.set_level(logging.INFO) + + # 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 CSV file + csv_path: Path = temp_dir / "report.csv" + from_day: int = 60 + to_day: int = 70 + # Call the method to generate the report + report_generator.generate_optimal_orientation_csv_report( + csv_path, irradiation_calculator, from_day, to_day + ) + + # Check the CSV file + with open(csv_path, "r") as file: + reader = csv.reader(file) + header = next(reader) + 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]), + float(row[1]), + float(row[2]), + ) + + 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 + ) + # Check the logs + for day in range(from_day, to_day): + assert any( + f"On day {day}," in record.message for record in caplog.records + ), f"No log message for day {day}" + + +def test_generate_optimal_orientation_json_report(caplog, tmpdir) -> None: + caplog.set_level(logging.INFO) + + # 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 + ) + # Check the logs + for day in range(from_day, to_day): + assert any( + f"On day {day}," in record.message for record in caplog.records + ), f"No log message for day {day}" + + +def test_generate_optimal_orientation_xml_report(caplog, tmpdir) -> None: + caplog.set_level(logging.INFO) + + # 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 + ) + # Check the logs + for day in range(from_day, to_day): + assert any( + f"On day {day}," in record.message for record in caplog.records + ), f"No log message for day {day}" diff --git a/tests/test_sun_position.py b/tests/test_sun_position.py new file mode 100644 index 0000000..5984e01 --- /dev/null +++ b/tests/test_sun_position.py @@ -0,0 +1,59 @@ +# Copyright 2023 Alireza Aghamohammadi + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import math + +import pytest + +from pysolorie import SunPosition + + +@pytest.mark.parametrize( + "day_of_year, solar_time, expected_declination, expected_hour_angle", + [ + (1, 12 * 60 * 60, -0.4014257279586958, 0), # January 1st at noon + (81, 10 * 60 * 60, 0, -math.pi / 6), # March 22nd at 10am (equinox) + (81, 12 * 60 * 60, 0, 0), # March 22nd at noon (equinox) + (1, 13 * 60 * 60, -0.4014257279586958, math.pi / 12), # January 1st at 1pm + ], +) +def test_sun_position( + day_of_year: int, + solar_time: int, + expected_declination: float, + expected_hour_angle: float, +) -> None: + sun_position: SunPosition = SunPosition() + declination: float = sun_position.solar_declination(day_of_year) + hour_angle: float = sun_position.hour_angle(solar_time) + assert declination == pytest.approx(expected_declination, abs=1e-3) + assert hour_angle == pytest.approx(expected_hour_angle, abs=1e-3) + + +@pytest.mark.parametrize( + "hour_angle, expected_solar_time", + [ + (0, 12 * 60 * 60), # solar noon + (-math.pi / 6, 10 * 60 * 60), # 10am + (math.pi / 12, 13 * 60 * 60), # 1pm + (math.pi, 24 * 60 * 60), # solar night + ], +) +def test_solar_time( + hour_angle: float, + expected_solar_time: int, +) -> None: + sun_position: SunPosition = SunPosition() + solar_time: float = sun_position.solar_time(hour_angle) + assert solar_time == pytest.approx(expected_solar_time, abs=1e-3)