diff --git a/docs/sphinx/source/reference/iotools.rst b/docs/sphinx/source/reference/iotools.rst index cbf89c71a7..f8da4e4186 100644 --- a/docs/sphinx/source/reference/iotools.rst +++ b/docs/sphinx/source/reference/iotools.rst @@ -226,6 +226,7 @@ Functions for reading irradiance/weather data files. iotools.read_epw iotools.parse_epw iotools.read_panond + iotools.read_pan_binary A :py:class:`~pvlib.location.Location` object may be created from metadata diff --git a/docs/sphinx/source/whatsnew/v0.13.1.rst b/docs/sphinx/source/whatsnew/v0.13.1.rst index 14ae31170b..54c6b958b2 100644 --- a/docs/sphinx/source/whatsnew/v0.13.1.rst +++ b/docs/sphinx/source/whatsnew/v0.13.1.rst @@ -19,6 +19,8 @@ Bug fixes Enhancements ~~~~~~~~~~~~ +* Add support for reading PAN binary files (PVsyst v6.39 and earlier) using :py:func:`pvlib.iotools.read_pan_binary`. + (:issue:`2504`) Documentation @@ -48,4 +50,5 @@ Contributors ~~~~~~~~~~~~ * Elijah Passmore (:ghuser:`eljpsm`) * Rajiv Daxini (:ghuser:`RDaxini`) -* Omar Bahamida (:ghuser:`OmarBahamida`) \ No newline at end of file +* Omar Bahamida (:ghuser:`OmarBahamida`) +* Kurt Rhee (:ghuser:`kurt-rhee`) diff --git a/pvlib/iotools/__init__.py b/pvlib/iotools/__init__.py index 352044e5cd..05db24158b 100644 --- a/pvlib/iotools/__init__.py +++ b/pvlib/iotools/__init__.py @@ -27,6 +27,7 @@ from pvlib.iotools.sodapro import read_cams # noqa: F401 from pvlib.iotools.sodapro import parse_cams # noqa: F401 from pvlib.iotools.panond import read_panond # noqa: F401 +from pvlib.iotools.pan_binary import read_pan_binary # noqa: F401 from pvlib.iotools.acis import get_acis_prism # noqa: F401 from pvlib.iotools.acis import get_acis_nrcc # noqa: F401 from pvlib.iotools.acis import get_acis_mpe # noqa: F401 diff --git a/pvlib/iotools/pan_binary.py b/pvlib/iotools/pan_binary.py new file mode 100644 index 0000000000..a670bfd539 --- /dev/null +++ b/pvlib/iotools/pan_binary.py @@ -0,0 +1,407 @@ +""" +Read older versions of PAN files created by PVsyst ( len(byte_array): + raise IndexError( + f"Not enough bytes: need {num_bytes} bytes starting at " + f"{start_index}" + ) + + # Extract the specified number of bytes starting at start_index + param_byte_sequence = byte_array[start_index:start_index + num_bytes] + + return param_byte_sequence + + +# This format might be specific to how PAN files format their floats +value_format = "{:.2f}" + + +def _extract_iam_profile(start_index, byte_array): + """ + Extract the IAM (Incidence Angle Modifier) profile. + + Parameters + ---------- + start_index : int + Starting index of the IAM data in the byte array + byte_array : bytes + Byte array containing the file data + + Returns + ------- + iam_profile : list of dict + List of dictionaries containing 'aoi' and 'modifier' values + """ + iam_profile = [] + + for i in range(0, 45, 5): # 0 to 44 step 5 (matches VB.NET loop) + # Extract AOI value + aoi_index = _get_param_index(start_index=start_index, offset_num=i) + aoi_bytes = _extract_byte_parameters( + byte_array=byte_array, start_index=aoi_index, num_bytes=6 + ) + aoi_raw = _read48_to_float(real48=aoi_bytes) + aoi_formatted = value_format.format(aoi_raw) # Keep for the check + + # Check if AOI is not null/empty (like VB.NET vbNullString check) + if aoi_formatted != "": + # Extract modifier value + modifier_index = _get_param_index( + start_index=start_index, offset_num=i + 1 + ) + modifier_bytes = _extract_byte_parameters( + byte_array=byte_array, start_index=modifier_index, num_bytes=6 + ) + modifier_raw = _read48_to_float(real48=modifier_bytes) + + # Add to profile (only if AOI is not empty) + iam_profile.append({"aoi": aoi_raw, "modifier": modifier_raw}) + # If AOI is empty, we skip this entry entirely (don't add to list) + return iam_profile + + +def read_pan_binary(filename): + """ + Retreive module data from a .pan binary file, + for PVsyst v6.39 and earlier. + + Parameters + ---------- + filename : str or path object + Name or path of a .pan binary file + + Returns + ------- + content : dict + Contents of the .pan file. + + Notes + ----- + The parser is intended for use with binary .pan files that were created for + PVsyst version 6.39 or earlier. At time of publication, no documentation + for these files was available. So, this parser is based on inferred logic, + rather than anything specified by PVsyst. + + The parser can only be used on binary .pan files. + For files that use the newer text format + please refer to `pvlib.iotools.panond.read_panond`. + + See also + -------- + pvlib.iotools.read_panond : for newer text-based data format + """ + data = {} + + # Read the file and convert to byte array + with open(filename, "rb") as file: + byte_array = file.read() + + if not byte_array: + raise ValueError("File is empty") + + # --- Find start indices for string parameters --- + try: + manu_start_index = _find_marker_index( + marker=SEMICOLON_MARKER, start_index=0, byte_array=byte_array + ) + panel_start_index = _find_marker_index( + marker=DOT_MARKER, start_index=0, byte_array=byte_array + ) + source_start_index = _find_marker_index( + marker=DOT_MARKER, + start_index=panel_start_index, + byte_array=byte_array + ) + version_start_index = _find_marker_index( + marker=DOUBLE_DOT_MARKER, + start_index=source_start_index, + byte_array=byte_array, + ) + version_end_index = _find_marker_index( + marker=SEMICOLON_MARKER, + start_index=version_start_index, + byte_array=byte_array, + ) + year_start_index = _find_marker_index( + marker=SEMICOLON_MARKER, + start_index=version_end_index, + byte_array=byte_array, + ) + technology_start_index = _find_marker_index( + marker=DOUBLE_DOT_MARKER, + start_index=year_start_index, + byte_array=byte_array, + ) + cells_in_series_start_index = _find_marker_index( + marker=SEMICOLON_MARKER, + start_index=technology_start_index, + byte_array=byte_array, + ) + cells_in_parallel_start_index = _find_marker_index( + marker=SEMICOLON_MARKER, + start_index=cells_in_series_start_index, + byte_array=byte_array, + ) + bypass_diodes_start_index = _find_marker_index( + marker=SEMICOLON_MARKER, + start_index=cells_in_parallel_start_index, + byte_array=byte_array, + ) + + # --- Find start of Real48 encoded data --- + cr_counter = 0 + real48_start_index = 0 + for i, byte in enumerate(byte_array): + if byte == CR_MARKER: + cr_counter += 1 + if cr_counter == 3: + real48_start_index = i + 2 # Skip + break + + if real48_start_index == 0: + return {"error": "Could not find start of Real48 data block."} + + # --- Extract string parameters --- + # Note: latin-1 is used as it can decode any byte value without error + data["Manufacturer"] = ( + byte_array[manu_start_index: panel_start_index - 1] + .decode("latin-1") + .strip() + ) + data["Model"] = ( + byte_array[panel_start_index: source_start_index - 1] + .decode("latin-1") + .strip() + ) + data["Source"] = ( + byte_array[source_start_index: version_start_index - 4] + .decode("latin-1") + .strip() + ) + data["Version"] = ( + byte_array[version_start_index: version_end_index - 2] + .decode("latin-1") + .replace("Version", "PVsyst") + .strip() + ) + data["Year"] = ( + byte_array[year_start_index: year_start_index + 4] + .decode("latin-1") + .strip() + ) + data["Technology"] = ( + byte_array[ + technology_start_index: cells_in_series_start_index - 1 + ] + .decode("latin-1") + .strip() + ) + data["Cells_In_Series"] = ( + byte_array[ + cells_in_series_start_index: cells_in_parallel_start_index - 1 + ] + .decode("latin-1") + .strip() + ) + data["Cells_In_Parallel"] = ( + byte_array[ + cells_in_parallel_start_index: bypass_diodes_start_index - 1 + ] + .decode("latin-1") + .strip() + ) + + # --- Parse Real48 encoded parameters --- + param_map = { + "PNom": 0, + "VMax": 1, + "Tolerance": 2, + "AreaM": 3, + "CellArea": 4, + "GRef": 5, + "TRef": 6, + "Isc": 8, + "muISC": 9, + "Voc": 10, + "muVocSpec": 11, + "Imp": 12, + "Vmp": 13, + "BypassDiodeVoltage": 14, + "RShunt": 17, + "RSerie": 18, + "RShunt_0": 23, + "RShunt_exp": 24, + "muPmp": 25, + } + + for name, offset in param_map.items(): + start = _get_param_index( + start_index=real48_start_index, offset_num=offset + ) + end = start + 6 + param_bytes = byte_array[start:end] + value = _read48_to_float(real48=param_bytes) + if name == "Tolerance": + value *= 100 # Convert to percentage + if value > 100: + value = 0.0 + data[name] = value + + # --- Check for and Parse IAM Profile --- + dot_counter = 0 + iam_start_index = 0 + dot_position = data["Version"].find(".") + major_version = int(data["Version"][dot_position - 1: dot_position]) + if major_version < 6: + for i in range(real48_start_index + 170, len(byte_array)): + if byte_array[i] == DOT_MARKER: + dot_counter += 1 + if dot_counter == 2: + iam_start_index = i + 4 + break + + if iam_start_index > 0: + data["IAMProfile"] = _extract_iam_profile( + start_index=iam_start_index, byte_array=byte_array + ) + + except (IndexError, TypeError, struct.error) as e: + raise ValueError( + "Unable to parse binary PAN file. Is this a binary file " + "and compatible with PVsyst up to 6.39?" + f"Error details: {str(e)}" + ) + + return data