diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b96a5204..8fe9facf 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,7 +9,7 @@ body: ## Bug Report Thanks for submitting a bug report to map2loop! Please use this template to report a bug. Please provide as much detail as possible to help us reproduce and fix the issue efficiently. - + - type: input id: bug_title attributes: @@ -80,8 +80,12 @@ body: description: "Select the severity level of the bug." options: - label: "Low" + value: "low" - label: "Medium" + value: "medium" - label: "High" + value: "high" - label: "Critical" + value: "critical" validations: - required: true \ No newline at end of file + required: true diff --git a/.github/ISSUE_TEMPLATE/documentation_request.yml b/.github/ISSUE_TEMPLATE/documentation_request.yml index d275cc6c..6fb074ad 100644 --- a/.github/ISSUE_TEMPLATE/documentation_request.yml +++ b/.github/ISSUE_TEMPLATE/documentation_request.yml @@ -7,8 +7,10 @@ body: attributes: value: | ## Documentation Request + Please use this template to suggest an improvement or addition to map2loop documentation. Provide as much detail as possible to help us understand and implement your request efficiently. + - type: input id: doc_title attributes: @@ -34,4 +36,4 @@ body: description: "Provide any other context or information that may be helpful." placeholder: "Enter any additional context" validations: - required: false \ No newline at end of file + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index c18daa34..00872c72 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -7,7 +7,9 @@ body: attributes: value: | ## Feature Request + Please use this template to submit your feature request. Provide as much detail as possible to help us understand and implement your request efficiently. + - type: input id: feature_title attributes: @@ -60,10 +62,16 @@ body: description: "Select the areas of the project that this feature request impacts." options: - label: "input data" + value: "input data" - label: "project creation" + value: "project creation" - label: "samplers" + value: "samplers" - label: "sorters" + value: "sorters" - label: "stratigraphic column" + value: "stratigraphic column" - label: "data types" - - label: "faults" - - label: "Other" \ No newline at end of file + value: "data types" + - label: "Other" + value: "other" diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml index 1348b55c..65db9e62 100644 --- a/.github/ISSUE_TEMPLATE/question.yml +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -7,8 +7,10 @@ body: attributes: value: | ## Question + Please use this template to ask a question about applying map2loop to your data. Provide as much detail as possible to help us understand and answer your question efficiently. + - type: input id: question_title attributes: @@ -43,4 +45,4 @@ body: description: "Provide any other context or information that may be helpful in answering your question." placeholder: "Enter any additional context" validations: - required: false \ No newline at end of file + required: false diff --git a/.github/ISSUE_TEMPLATE/pull_request_template.md b/.github/pull_request_template.md similarity index 100% rename from .github/ISSUE_TEMPLATE/pull_request_template.md rename to .github/pull_request_template.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f7d2af82..ec818224 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## [3.1.6](https://github.com/Loop3D/map2loop/compare/v3.1.5...v3.1.6) (2024-06-13) + + +### Bug Fixes + +* add invalid hex input handling ([40028a8](https://github.com/Loop3D/map2loop/commit/40028a87b06c7610095edb0b78ec451838ff85a6)) +* add the suggestions ([dc85b2e](https://github.com/Loop3D/map2loop/commit/dc85b2e5d89475511904275fc482d3bfd3f33fd8)) +* comment the code ([6e064b1](https://github.com/Loop3D/map2loop/commit/6e064b1a3cb53af1fa92ea51faa4917d2c50663c)) +* fix for duplicate units ([4052eca](https://github.com/Loop3D/map2loop/commit/4052eca85825a8ddd9d3c2f6c57c3f838ae2dd3e)) +* make sure all colours are unique ([f90159a](https://github.com/Loop3D/map2loop/commit/f90159a83691278ee7cfca07c3aba64a144816c8)) +* small fixes - double check for integer & add test information at the beginning of the test scripts ([a916343](https://github.com/Loop3D/map2loop/commit/a916343fba5ec59602fab9892b7d67f86702723b)) +* update the tests for new function names (hex_to_rgb) ([e1778b7](https://github.com/Loop3D/map2loop/commit/e1778b734ca13d4c422daacd29de41c505f92fea)) + +## [3.1.5](https://github.com/Loop3D/map2loop/compare/v3.1.4...v3.1.5) (2024-06-06) + + +### Bug Fixes + +* add 2 more links to try from GA WCS if timing out & prints out where the DEM was downloaded from ([92f73a5](https://github.com/Loop3D/map2loop/commit/92f73a55ad998af76fe02fd09702d98492c6e431)) +* add catchall exception to mapdata_parse_structure & comment code ([59c677c](https://github.com/Loop3D/map2loop/commit/59c677c5988c1e411723c61f3482dbd792ed19a7)) +* add test for all structures less than 360 ([f08c42a](https://github.com/Loop3D/map2loop/commit/f08c42a7e455657c0d042e1346fa5ce5fdb98775)) +* allow 2 minutes for server connection & add add available ga server link ([900a50d](https://github.com/Loop3D/map2loop/commit/900a50d34f44e50c508466d617e4ce8d85c0c316)) +* avoid double imports ([3347751](https://github.com/Loop3D/map2loop/commit/334775120d8db88a82e70980f68940d024524687)) +* create the tmp file function missing ([55688fe](https://github.com/Loop3D/map2loop/commit/55688fe85f7aca2678a15160a73a972427a3bf34)) +* ensure all dipdir vals < 360 ([cf21a6b](https://github.com/Loop3D/map2loop/commit/cf21a6ba8cc0c48dfb7cc442d59a6cd63678ab83)) +* fix for altitude not being saved properly in LPF ([b2c6638](https://github.com/Loop3D/map2loop/commit/b2c663866f478752670aca41a188aa195df9c90f)) +* make the templates a bit easier to fill out ([81f54fe](https://github.com/Loop3D/map2loop/commit/81f54fe9f7163dc997219b4fe092c81b6c7b3ca3)) +* move location of the PR template ([9209c25](https://github.com/Loop3D/map2loop/commit/9209c251520bfd993a51b04124ad62a831a58922)) +* return just column with new vals instead of inplace modification ([722b98c](https://github.com/Loop3D/map2loop/commit/722b98cdbcf1f820b9d1a480f09ac59d287f1a04)) +* revert back to original ([6ed00c9](https://github.com/Loop3D/map2loop/commit/6ed00c999901c3236e53fc039930510be9d38570)) +* revert back to original timeout ([301242f](https://github.com/Loop3D/map2loop/commit/301242f931b92ac01c4251ecf9039119d9da49d5)) +* revert back to original url ([26e4971](https://github.com/Loop3D/map2loop/commit/26e497198744c591879fea96c10935d6b79b7d9d)) +* update question template ([8182e50](https://github.com/Loop3D/map2loop/commit/8182e50361852de817e1440adcb278b5f46f4796)) +* update tests that depend on server to skip in case server is unavailable ([646be9e](https://github.com/Loop3D/map2loop/commit/646be9ea06dade92f16208da7e681d6d2b0e6c65)) +* use gpd.points_from_xy ([2f931c5](https://github.com/Loop3D/map2loop/commit/2f931c59025997133aeea9c10f725858613b7f6b)) +* verbose dipdir 360 test name ([6ffe6bf](https://github.com/Loop3D/map2loop/commit/6ffe6bf31ab860ee054bb175a63c00fee7b8a7ff)) + + +### Documentation + +* update libtiff version ([0a99ac8](https://github.com/Loop3D/map2loop/commit/0a99ac8aaf6980196216137f15060c44b2f77cd9)) + ## [3.1.4](https://github.com/Loop3D/map2loop/compare/v3.1.3...v3.1.4) (2024-05-29) diff --git a/docs/Dockerfile b/docs/Dockerfile index 86879a31..547af1fb 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -11,7 +11,7 @@ RUN apt-get update -qq && \ make\ libgl1\ libtinfo5\ - libtiff5\ + libtiff6\ libgl1-mesa-glx diff --git a/docs/examples/plot_hamersley.py b/docs/examples/plot_hamersley.py index 5ba4f6c9..16ca3873 100644 --- a/docs/examples/plot_hamersley.py +++ b/docs/examples/plot_hamersley.py @@ -5,7 +5,7 @@ """ from map2loop.project import Project -from map2loop.m2l_enums import VerboseLevel, Datatype +from map2loop.m2l_enums import VerboseLevel, Datatype, SampleType from map2loop.sorter import SorterAlpha from map2loop.sampler import SamplerSpacing @@ -36,8 +36,8 @@ ) # Set the distance between sample points for arial and linestring geometry -proj.set_sampler(Datatype.GEOLOGY, SamplerSpacing(200.0)) -proj.set_sampler(Datatype.FAULT, SamplerSpacing(200.0)) +proj.sample_supervisor.set_sampler(SampleType.GEOLOGY, SamplerSpacing(200.0)) +proj.sample_supervisor.set_sampler(SampleType.FAULT, SamplerSpacing(200.0)) # Choose which stratigraphic sorter to use or run_all with "take_best" flag to run them all proj.set_sorter(SorterAlpha()) diff --git a/map2loop/m2l_enums.py b/map2loop/m2l_enums.py index f390361a..2c642d0b 100644 --- a/map2loop/m2l_enums.py +++ b/map2loop/m2l_enums.py @@ -1,5 +1,14 @@ from enum import IntEnum +class SampleType(IntEnum): + GEOLOGY = 0 + STRUCTURE = 1 + FAULT = 2 + FOLD = 3 + FAULT_ORIENTATION = 4 + CONTACT = 5 + DTM = 6 + class Datatype(IntEnum): GEOLOGY = 0 @@ -30,3 +39,8 @@ class VerboseLevel(IntEnum): NONE = 0 TEXTONLY = 1 ALL = 2 + + +class StateType(IntEnum): + DATA = 0 + SAMPLER = 1 diff --git a/map2loop/mapdata.py b/map2loop/mapdata.py index 1280e883..97138b39 100644 --- a/map2loop/mapdata.py +++ b/map2loop/mapdata.py @@ -1,3 +1,10 @@ +# internal imports +from .m2l_enums import Datatype, Datastate, VerboseLevel +from .config import Config +from .aus_state_urls import AustraliaStateUrls +from .utils import generate_random_hex_colors + +# external imports import geopandas import pandas import numpy @@ -11,12 +18,8 @@ import beartype import os from io import BytesIO -from .m2l_enums import Datatype, Datastate -from .m2l_enums import VerboseLevel -from .config import Config -from .aus_state_urls import AustraliaStateUrls -from .random_colour import random_colours_hex from typing import Union +import requests class MapData: @@ -290,7 +293,7 @@ def set_ignore_codes(self, codes: list): @beartype.beartype def get_ignore_codes(self) -> list: """ - Get the list of codes to ingnore + Get the list of codes to ignore Returns: list: The list of strings to ignore @@ -482,6 +485,40 @@ def __check_and_create_tmp_path(self): if not os.path.isdir(self.tmp_path): os.mkdir(self.tmp_path) + + @beartype.beartype + def get_coverage(self, url: str, bb_ll:tuple): + """ + Retrieves coverage from GA WCS and save it as a GeoTIFF file. + + This method retrieves a coverage from the specified WCS GA URL using the project's bounding box. + The retrieved coverage is saved to a temporary file --> StudidGDALLocalFile.tif + + Args: + url (str): The GA WCS URL from which to retrieve the coverage. + bb_ll (tuple): A tuple containing the bounding box coordinates (minX, minY, maxX, maxY). + + Returns: + gdal.Dataset: The GDAL dataset of the retrieved GeoTIFF file. + """ + + self.__check_and_create_tmp_path() + + wcs = WebCoverageService(url, version="1.0.0") + + coverage = wcs.getCoverage( + identifier="1", bbox=bb_ll, format="GeoTIFF", crs=4326, width=2048, height=2048 + ) + # This is stupid that gdal cannot read a byte stream and has to have a + # file on the local system to open or otherwise create a gdal file + # from scratch with Create + tmp_file = os.path.join(self.tmp_path, "StupidGDALLocalFile.tif") + + with open(tmp_file, "wb") as fh: + fh.write(coverage.read()) + + return gdal.Open(tmp_file) + @beartype.beartype def __retrieve_tif(self, filename: str): """ @@ -495,29 +532,37 @@ def __retrieve_tif(self, filename: str): _type_: The open geotiff in a gdal handler """ self.__check_and_create_tmp_path() + # For gdal debugging use exceptions gdal.UseExceptions() bb_ll = tuple(self.bounding_box_polygon.to_crs("EPSG:4326").geometry.total_bounds) - # try: + if filename.lower() == "aus" or filename.lower() == "au": + url = "http://services.ga.gov.au/gis/services/DEM_SRTM_1Second_over_Bathymetry_Topography/MapServer/WCSServer?" wcs = WebCoverageService(url, version="1.0.0") + coverage = wcs.getCoverage( identifier="1", bbox=bb_ll, format="GeoTIFF", crs=4326, width=2048, height=2048 ) # This is stupid that gdal cannot read a byte stream and has to have a # file on the local system to open or otherwise create a gdal file # from scratch with Create + tmp_file = os.path.join(self.tmp_path, "StupidGDALLocalFile.tif") + with open(tmp_file, "wb") as fh: fh.write(coverage.read()) tif = gdal.Open(tmp_file) + elif filename == "hawaii": import netCDF4 bbox_str = ( f"[({str(bb_ll[1])}):1:({str(bb_ll[3])})][({str(bb_ll[0])}):1:({str(bb_ll[2])})]" ) + + filename = f"https://pae-paha.pacioos.hawaii.edu/erddap/griddap/srtm30plus_v11_land.nc?elev{bbox_str}" f = urllib.request.urlopen(filename) ds = netCDF4.Dataset("in-mem-file", mode="r", memory=f.read()) @@ -723,7 +768,10 @@ def parse_structure_map(self) -> tuple: structure["DIPDIR"] = self.raw_data[Datatype.STRUCTURE][config["dipdir_column"]] else: print(f"Structure map does not contain dipdir_column '{config['dipdir_column']}'") - + + # Ensure all DIPDIR values are within [0, 360] + structure["DIPDIR"] = structure["DIPDIR"] % 360.0 + if config["dip_column"] in self.raw_data[Datatype.STRUCTURE]: structure["DIP"] = self.raw_data[Datatype.STRUCTURE][config["dip_column"]] else: @@ -1442,7 +1490,9 @@ def extract_basal_contacts(self, stratigraphic_column: list, save_contacts=True) return basal_contacts @beartype.beartype - def colour_units(self, stratigraphic_units: pandas.DataFrame, random: bool = False): + def colour_units( + self, stratigraphic_units: pandas.DataFrame, random: bool = False + ) -> pandas.DataFrame: """ Add a colour column to the units in the stratigraphic units structure @@ -1457,12 +1507,27 @@ def colour_units(self, stratigraphic_units: pandas.DataFrame, random: bool = Fal """ colour_lookup = pandas.DataFrame(columns=["UNITNAME", "colour"]) - try: - colour_lookup = pandas.read_csv(self.colour_filename, sep=",") - except FileNotFoundError: - print(f"Colour Lookup file {self.colour_filename} not found") + + if self.colour_filename is not None: + try: + colour_lookup = pandas.read_csv(self.colour_filename, sep=",") + except FileNotFoundError: + print( + f"Colour Lookup file {self.colour_filename} not found. Assigning random colors to units" + ) + self.colour_filename = None + + if self.colour_filename is None: + print("No colour configuration file found. Assigning random colors to units") + missing_colour_n = len(stratigraphic_units["colour"]) + stratigraphic_units.loc[stratigraphic_units["colour"].isna(), "colour"] = ( + generate_random_hex_colors(missing_colour_n) + ) colour_lookup["colour"] = colour_lookup["colour"].str.upper() + # if there are duplicates in the clut file, drop. + colour_lookup = colour_lookup.drop_duplicates(subset=["UNITNAME"]) + if "UNITNAME" in colour_lookup.columns and "colour" in colour_lookup.columns: stratigraphic_units = stratigraphic_units.merge( colour_lookup, @@ -1471,8 +1536,8 @@ def colour_units(self, stratigraphic_units: pandas.DataFrame, random: bool = Fal suffixes=("_old", ""), how="left", ) - stratigraphic_units.loc[stratigraphic_units["colour"].isna(), ["colour"]] = ( - random_colours_hex(stratigraphic_units["colour"].isna().sum()) + stratigraphic_units.loc[stratigraphic_units["colour"].isna(), "colour"] = ( + generate_random_hex_colors(int(stratigraphic_units["colour"].isna().sum())) ) stratigraphic_units.drop(columns=["UNITNAME", "colour_old"], inplace=True) else: diff --git a/map2loop/project.py b/map2loop/project.py index 02630ebc..31421bc7 100644 --- a/map2loop/project.py +++ b/map2loop/project.py @@ -1,7 +1,7 @@ -import beartype -import pathlib +# internal imports from map2loop.fault_orientation import FaultOrientationNearest -from .m2l_enums import VerboseLevel, ErrorState, Datatype +from .utils import hex_to_rgba +from .m2l_enums import VerboseLevel, ErrorState, Datatype, SampleType from .mapdata import MapData from .sampler import Sampler, SamplerDecimator, SamplerSpacing from .thickness_calculator import ThicknessCalculator, ThicknessCalculatorAlpha @@ -11,17 +11,20 @@ from .stratigraphic_column import StratigraphicColumn from .deformation_history import DeformationHistory from .map2model_wrapper import Map2ModelWrapper + +# external imports +from .sample_storage import SampleSupervisor import LoopProjectFile as LPF from typing import Union +from osgeo import gdal +import geopandas +import beartype +import pathlib import numpy import pandas -import geopandas import os import re -from matplotlib.colors import to_rgba -from osgeo import gdal - class Project(object): """ @@ -120,8 +123,6 @@ def __init__( self._error_state = ErrorState.NONE self._error_state_msg = "" self.verbose_level = verbose_level - self.samplers = [SamplerDecimator()] * len(Datatype) - self.set_default_samplers() self.sorter = SorterUseHint() self.thickness_calculator = ThicknessCalculatorAlpha() self.throw_calculator = ThrowCalculatorAlpha() @@ -134,6 +135,7 @@ def __init__( self.stratigraphic_column = StratigraphicColumn() self.deformation_history = DeformationHistory() + self.sample_supervisor = SampleSupervisor(self) self.fault_orientations = pandas.DataFrame( columns=["ID", "DIPDIR", "DIP", "X", "Y", "Z", "featureId"] ) @@ -313,42 +315,6 @@ def get_throw_calculator(self): """ return self.throw_calculator.throw_calculator_label - def set_default_samplers(self): - """ - Initialisation function to set or reset the point samplers - """ - self.samplers[Datatype.STRUCTURE] = SamplerDecimator(1) - self.samplers[Datatype.GEOLOGY] = SamplerSpacing(50.0) - self.samplers[Datatype.FAULT] = SamplerSpacing(50.0) - self.samplers[Datatype.FOLD] = SamplerSpacing(50.0) - self.samplers[Datatype.DTM] = SamplerSpacing(50.0) - - @beartype.beartype - def set_sampler(self, datatype: Datatype, sampler: Sampler): - """ - Set the point sampler for a specific datatype - - Args: - datatype (Datatype): - The datatype to use this sampler on - sampler (Sampler): - The sampler to use - """ - self.samplers[datatype] = sampler - - @beartype.beartype - def get_sampler(self, datatype: Datatype): - """ - Get the sampler name being used for a datatype - - Args: - datatype (Datatype): The datatype of the sampler - - Returns: - str: The name of the sampler being used on the specified datatype - """ - return self.samplers[datatype].sampler_label - @beartype.beartype def set_minimum_fault_length(self, length: float): """ @@ -370,34 +336,16 @@ def get_minimum_fault_length(self) -> float: """ return self.deformation_history.get_minimum_fault_length() - # Processing functions - def sample_map_data(self): - """ - Use the samplers to extract points along polylines or unit boundaries - """ - self.geology_samples = self.samplers[Datatype.GEOLOGY].sample( - self.map_data.get_map_data(Datatype.GEOLOGY), self.map_data - ) - self.structure_samples = self.samplers[Datatype.STRUCTURE].sample( - self.map_data.get_map_data(Datatype.STRUCTURE), self.map_data - ) - self.fault_samples = self.samplers[Datatype.FAULT].sample( - self.map_data.get_map_data(Datatype.FAULT), self.map_data - ) - self.fold_samples = self.samplers[Datatype.FOLD].sample( - self.map_data.get_map_data(Datatype.FOLD), self.map_data - ) - def extract_geology_contacts(self): """ Use the stratigraphic column, and fault and geology data to extract points along contacts """ # Use stratigraphic column to determine basal contacts self.map_data.extract_basal_contacts(self.stratigraphic_column.column) - self.map_data.sampled_contacts = self.samplers[Datatype.GEOLOGY].sample( - self.map_data.basal_contacts + self.sample_supervisor.process(SampleType.CONTACT) + self.map_data.get_value_from_raster_df( + Datatype.DTM, self.sample_supervisor(SampleType.CONTACT) ) - self.map_data.get_value_from_raster_df(Datatype.DTM, self.map_data.sampled_contacts) def calculate_stratigraphic_order(self, take_best=False): """ @@ -453,7 +401,7 @@ def calculate_unit_thicknesses(self): self.stratigraphic_column.stratigraphicUnits, self.stratigraphic_column.column, self.map_data.basal_contacts, - self.structure_samples, + self.sample_supervisor, self.map_data, ) @@ -484,9 +432,11 @@ def summarise_fault_data(self): """ Use the fault shapefile to make a summary of each fault by name """ - self.map_data.get_value_from_raster_df(Datatype.DTM, self.fault_samples) - self.deformation_history.summarise_data(self.fault_samples) + self.map_data.get_value_from_raster_df( + Datatype.DTM, self.sample_supervisor(SampleType.FAULT) + ) + self.deformation_history.summarise_data(self.sample_supervisor(SampleType.FAULT)) self.deformation_history.faults = self.throw_calculator.compute( self.deformation_history.faults, self.stratigraphic_column.column, @@ -518,7 +468,6 @@ def run_all(self, user_defined_stratigraphic_column=None, take_best=False): # Calculate basal contacts based on stratigraphic column self.extract_geology_contacts() - self.sample_map_data() self.calculate_unit_thicknesses() self.calculate_fault_orientations() self.summarise_fault_data() @@ -642,41 +591,63 @@ def save_into_projectfile(self): LPF.Set(self.loop_filename, "stratigraphicLog", data=stratigraphic_data) # Save contacts - contacts_data = numpy.zeros(len(self.map_data.sampled_contacts), LPF.contactObservationType) - contacts_data["layerId"] = self.map_data.sampled_contacts["ID"] - contacts_data["easting"] = self.map_data.sampled_contacts["X"] - contacts_data["northing"] = self.map_data.sampled_contacts["Y"] - contacts_data["altitude"] = self.map_data.sampled_contacts["Z"] - contacts_data["featureId"] = self.map_data.sampled_contacts["featureId"] - LPF.Set(self.loop_filename, "contacts", data=contacts_data) + contacts_data = numpy.zeros( + len(self.sample_supervisor(SampleType.CONTACT)), LPF.contactObservationType + ) + contacts_data["layerId"] = self.sample_supervisor(SampleType.CONTACT)["ID"] + contacts_data["easting"] = self.sample_supervisor(SampleType.CONTACT)["X"] + contacts_data["northing"] = self.sample_supervisor(SampleType.CONTACT)["Y"] + contacts_data["altitude"] = self.sample_supervisor(SampleType.CONTACT)["Z"] + LPF.Set(self.loop_filename, "contacts", data=contacts_data, verbose=True) # Save fault trace information faults_obs_data = numpy.zeros( - len(self.fault_samples) + len(self.fault_orientations), LPF.faultObservationType + len(self.sample_supervisor(SampleType.FAULT)) + len(self.fault_orientations), + LPF.faultObservationType, ) faults_obs_data["val"] = numpy.nan - faults_obs_data["eventId"][0 : len(self.fault_samples)] = self.fault_samples["ID"] - faults_obs_data["easting"][0 : len(self.fault_samples)] = self.fault_samples["X"] - faults_obs_data["northing"][0 : len(self.fault_samples)] = self.fault_samples["Y"] - faults_obs_data["altitude"][0 : len(self.fault_samples)] = self.fault_samples["Z"] - faults_obs_data["featureId"][0 : len(self.fault_samples)] = self.fault_samples["featureId"] - faults_obs_data["dipDir"][0 : len(self.fault_samples)] = numpy.nan - faults_obs_data["dip"][0 : len(self.fault_samples)] = numpy.nan - faults_obs_data["posOnly"][0 : len(self.fault_samples)] = 1 + faults_obs_data["eventId"][0 : len(self.sample_supervisor(SampleType.FAULT))] = ( + self.sample_supervisor(SampleType.FAULT)["ID"] + ) + faults_obs_data["easting"][0 : len(self.sample_supervisor(SampleType.FAULT))] = ( + self.sample_supervisor(SampleType.FAULT)["X"] + ) + faults_obs_data["northing"][0 : len(self.sample_supervisor(SampleType.FAULT))] = ( + self.sample_supervisor(SampleType.FAULT)["Y"] + ) + faults_obs_data["altitude"][0 : len(self.sample_supervisor(SampleType.FAULT))] = ( + self.sample_supervisor(SampleType.FAULT)["Z"] + ) + faults_obs_data["featureId"][0 : len(self.sample_supervisor(SampleType.FAULT))] = self.sample_supervisor(SampleType.FAULT)["featureId"] + faults_obs_data["dipDir"][0 : len(self.sample_supervisor(SampleType.FAULT))] = numpy.nan + faults_obs_data["dip"][0 : len(self.sample_supervisor(SampleType.FAULT))] = numpy.nan + faults_obs_data["posOnly"][0 : len(self.sample_supervisor(SampleType.FAULT))] = 1 faults_obs_data["displacement"] = ( 100 # self.fault_samples["DISPLACEMENT"] #TODO remove note needed ) - faults_obs_data["eventId"][len(self.fault_samples) :] = self.fault_orientations["ID"] - faults_obs_data["easting"][len(self.fault_samples) :] = self.fault_orientations["X"] - faults_obs_data["northing"][len(self.fault_samples) :] = self.fault_orientations["Y"] - faults_obs_data["altitude"][len(self.fault_samples) :] = self.fault_orientations["Z"] - faults_obs_data["featureId"][len(self.fault_samples) :] = self.fault_orientations[ + faults_obs_data["eventId"][len(self.sample_supervisor(SampleType.FAULT)) :] = ( + self.fault_orientations["ID"] + ) + faults_obs_data["easting"][len(self.sample_supervisor(SampleType.FAULT)) :] = ( + self.fault_orientations["X"] + ) + faults_obs_data["northing"][len(self.sample_supervisor(SampleType.FAULT)) :] = ( + self.fault_orientations["Y"] + ) + faults_obs_data["altitude"][len(self.sample_supervisor(SampleType.FAULT)) :] = ( + self.fault_orientations["Z"] + ) + faults_obs_data["featureId"][len(self.sample_supervisor(SampleType.FAULT)) :] = self.fault_orientations[ "featureId" ] - faults_obs_data["dipDir"][len(self.fault_samples) :] = self.fault_orientations["DIPDIR"] - faults_obs_data["dip"][len(self.fault_samples) :] = self.fault_orientations["DIP"] - faults_obs_data["posOnly"][len(self.fault_samples) :] = 0 + faults_obs_data["dipDir"][len(self.sample_supervisor(SampleType.FAULT)) :] = ( + self.fault_orientations["DIPDIR"] + ) + faults_obs_data["dip"][len(self.sample_supervisor(SampleType.FAULT)) :] = ( + self.fault_orientations["DIP"] + ) + faults_obs_data["posOnly"][len(self.sample_supervisor(SampleType.FAULT)) :] = 0 LPF.Set(self.loop_filename, "faultObservations", data=faults_obs_data) faults = self.deformation_history.get_faults_for_export() @@ -705,15 +676,17 @@ def save_into_projectfile(self): LPF.Set(self.loop_filename, "faultLog", data=faults_data) # Save structural information - observations = numpy.zeros(len(self.structure_samples), LPF.stratigraphicObservationType) + observations = numpy.zeros( + len(self.sample_supervisor(SampleType.STRUCTURE)), LPF.stratigraphicObservationType + ) observations["layer"] = "s0" - observations["layerId"] = self.structure_samples["layerID"] - observations["easting"] = self.structure_samples["X"] - observations["northing"] = self.structure_samples["Y"] - # observations["altitude"] = self.structure_samples["Z"] - observations["dipDir"] = self.structure_samples["DIPDIR"] - observations["dip"] = self.structure_samples["DIP"] - observations["dipPolarity"] = self.structure_samples["OVERTURNED"] + observations["layerId"] = self.sample_supervisor(SampleType.STRUCTURE)["layerID"] + observations["easting"] = self.sample_supervisor(SampleType.STRUCTURE)["X"] + observations["northing"] = self.sample_supervisor(SampleType.STRUCTURE)["Y"] + observations["altitude"] = self.sample_supervisor(SampleType.STRUCTURE)["Z"] + observations["dipDir"] = self.sample_supervisor(SampleType.STRUCTURE)["DIPDIR"] + observations["dip"] = self.sample_supervisor(SampleType.STRUCTURE)["DIP"] + observations["dipPolarity"] = self.sample_supervisor(SampleType.STRUCTURE)["OVERTURNED"] LPF.Set(self.loop_filename, "stratigraphicObservations", data=observations) if self.map2model.fault_fault_relationships is not None: @@ -747,7 +720,7 @@ def draw_geology_map(self, points: pandas.DataFrame = None, overlay: str = ""): ) geol = self.map_data.get_map_data(Datatype.GEOLOGY).copy() geol["colour"] = geol.apply(lambda row: colour_lookup[row.UNITNAME], axis=1) - geol["colour_rgba"] = geol.apply(lambda row: to_rgba(row["colour"], 1.0), axis=1) + geol["colour_rgba"] = geol.apply(lambda row: hex_to_rgb(row["colour"]), axis=1) if points is None and overlay == "": geol.plot(color=geol["colour_rgba"]) return diff --git a/map2loop/random.py b/map2loop/random.py deleted file mode 100644 index 0fb00ca2..00000000 --- a/map2loop/random.py +++ /dev/null @@ -1,3 +0,0 @@ -import numpy as np - -rng = np.random.default_rng() diff --git a/map2loop/random_colour.py b/map2loop/random_colour.py deleted file mode 100644 index 8d2b8891..00000000 --- a/map2loop/random_colour.py +++ /dev/null @@ -1,23 +0,0 @@ -from .random import rng - - -def random_colours_hex(n): - """ - Generate n random colours in hex format. - """ - rgb = rng.random((n, 3)) - return rgb_to_hex(rgb) - - -def rgb_to_hex(rgb): - """ - Convert rgb values in the range [0,1] to hex format. - """ - return [rgb_to_hex_single(r, g, b) for r, g, b in rgb] - - -def rgb_to_hex_single(r, g, b): - """ - Convert rgb values in the range [0,1] to hex format. - """ - return "#%02x%02x%02x" % (int(r * 255), int(g * 255), int(b * 255)) diff --git a/map2loop/sample_storage.py b/map2loop/sample_storage.py new file mode 100644 index 00000000..6a4611cd --- /dev/null +++ b/map2loop/sample_storage.py @@ -0,0 +1,268 @@ +from abc import ABC, abstractmethod +from .m2l_enums import Datatype, SampleType, StateType +from .sampler import SamplerDecimator, SamplerSpacing, Sampler +import beartype +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .project import Project + + +class AccessStorage(ABC): + def __init__(self): + self.storage_label = "AccessStorageAbstractClass" + + def type(self): + return self.storage_label + + @beartype.beartype + @abstractmethod + def store(self, sampletype: SampleType, data): + pass + + @beartype.beartype + @abstractmethod + def check_state(self, sampletype: SampleType): + pass + + @abstractmethod + def load(self, sampletype: SampleType): + pass + + @beartype.beartype + @abstractmethod + def process(self, sampletype: SampleType): + pass + + @beartype.beartype + @abstractmethod + def reprocess(self, sampletype: SampleType): + pass + + @beartype.beartype + @abstractmethod + def __call__(self, sampletype: SampleType): + pass + + +class SampleSupervisor(AccessStorage): + """ + The SampleSupervisor class is responsible for managing the samples and samplers in the project. + It extends the AccessStorage abstract base class. + + Attributes: + storage_label (str): The label of the storage. + samples (list): A list of samples. + samplers (list): A list of samplers. + sampler_dirtyflags (list): A list of flags indicating if the sampler has changed. + dirtyflags (list): A list of flags indicating the state of the data, sample or sampler. + project (Project): The project associated with the SampleSupervisor. + map_data (MapData): The map data associated with the project. + """ + + def __init__(self, project: "Project"): + """ + The constructor for the SampleSupervisor class. + + Args: + project (Project): The Project class associated with the SampleSupervisor. + """ + + self.storage_label = "SampleSupervisor" + self.samples = [None] * len(SampleType) + self.samplers = [None] * len(SampleType) + self.sampler_dirtyflags = [True] * len(SampleType) + self.dirtyflags = [True] * len(StateType) + self.set_default_samplers() + self.project = project + self.map_data = project.map_data + + def type(self): + return self.storage_label + + def set_default_samplers(self): + """ + Initialisation function to set or reset the point samplers + """ + self.samplers[SampleType.STRUCTURE] = SamplerDecimator(1) + self.samplers[SampleType.FAULT_ORIENTATION] = SamplerDecimator(1) + self.samplers[SampleType.GEOLOGY] = SamplerSpacing(50.0) + self.samplers[SampleType.FAULT] = SamplerSpacing(50.0) + self.samplers[SampleType.FOLD] = SamplerSpacing(50.0) + self.samplers[SampleType.CONTACT] = SamplerSpacing(50.0) + self.samplers[SampleType.DTM] = SamplerSpacing(50.0) + # dirty flags to false after initialisation + self.sampler_dirtyflags = [False] * len(SampleType) + + @beartype.beartype + def set_sampler(self, sampletype: SampleType, sampler: Sampler): + """ + Set the point sampler for a specific datatype + + Args: + sampletype (Datatype): + The sample type (SampleType) to use this sampler on + sampler (Sampler): + The sampler to use + """ + self.samplers[sampletype] = sampler + # set the dirty flag to True to indicate that the sampler has changed + self.sampler_dirtyflags[sampletype] = True + + @beartype.beartype + def get_sampler(self, sampletype: SampleType): + """ + Get the sampler name being used for a datatype + + Args: + sampletype: The sample type of the sampler + + Returns: + str: The name of the sampler being used on the specified datatype + """ + return self.samplers[sampletype].sampler_label + + @beartype.beartype + def store(self, sampletype: SampleType, data): + """ + Stores the sample data. + + Args: + sampletype (SampleType): The type of the sample. + data: The sample data to store. + """ + + # store the sample data + self.samples[sampletype] = data + self.sampler_dirtyflags[sampletype] = False + + @beartype.beartype + def check_state(self, sampletype: SampleType): + """ + Checks the state of the data, sample and sampler. + + Args: + sampletype (SampleType): The type of the sample. + """ + + self.dirtyflags[StateType.DATA] = self.map_data.dirtyflags[sampletype] + self.dirtyflags[StateType.SAMPLER] = self.sampler_dirtyflags[sampletype] + + @beartype.beartype + def load(self, sampletype: SampleType): + """ + Loads the map data or raster map data based on the sample type. + + Args: + sampletype (SampleType): The type of the sample. + """ + datatype = Datatype(sampletype) + + if datatype == Datatype.DTM: + self.map_data.load_raster_map_data(datatype) + + else: + # load map data + self.map_data.load_map_data(datatype) + + @beartype.beartype + def process(self, sampletype: SampleType): + """ + Processes the sample based on the sample type. + + Args: + sampletype (SampleType): The type of the sample. + """ + + if sampletype == SampleType.CONTACT: + self.store( + SampleType.CONTACT, + self.samplers[SampleType.CONTACT].sample( + self.map_data.basal_contacts, self.map_data + ), + ) + + else: + datatype = Datatype(sampletype) + self.store( + sampletype, + self.samplers[sampletype].sample( + self.map_data.get_map_data(datatype), self.map_data + ), + ) + + @beartype.beartype + def reprocess(self, sampletype: SampleType): + """ + Reprocesses the data based on the sample type. + + Args: + sampletype (SampleType): The type of the sample. + """ + + if sampletype == SampleType.GEOLOGY or sampletype == SampleType.CONTACT: + self.map_data.extract_all_contacts() + + if self.project.stratigraphic_column.column is None: + self.project.calculate_stratigraphic_order() + + else: + self.project.sort_stratigraphic_column() + + self.project.extract_geology_contacts() + self.process(SampleType.GEOLOGY) + + elif sampletype == SampleType.STRUCTURE: + self.process(SampleType.STRUCTURE) + + elif sampletype == SampleType.FAULT: + self.project.calculate_fault_orientations() + self.project.summarise_fault_data() + self.process(SampleType.FAULT) + + elif sampletype == SampleType.FOLD: + self.process(SampleType.FOLD) + + @beartype.beartype + def __call__(self, sampletype: SampleType): + """ + Checks the state of the data, sample and sampler, and returns + the requested sample after reprocessing if necessary. + + Args: + sampletype (SampleType): The type of the sample. + + Returns: + The requested sample. + """ + + # check the state of the data, sample and sampler + self.check_state(sampletype) + + # run the sampler only if no sample was generated before + if self.samples[sampletype] is None: + # if the data is changed, load and reprocess the data and generate a new sample + if self.dirtyflags[StateType.DATA] is True: + self.load(sampletype) + self.reprocess(sampletype) + return self.samples[sampletype] + + if self.dirtyflags[StateType.DATA] is False: + self.process(sampletype) + return self.samples[sampletype] + + # return the requested sample after reprocessing if the data is changed + elif self.samples[sampletype] is not None: + if self.dirtyflags[StateType.DATA] is False: + if self.dirtyflags[StateType.SAMPLER] is True: + self.reprocess(sampletype) + return self.samples[sampletype] + + if self.dirtyflags[StateType.SAMPLER] is False: + return self.samples[sampletype] + + if self.dirtyflags[StateType.DATA] is True: + + self.load(sampletype) + self.reprocess(sampletype) + return self.samples[sampletype] \ No newline at end of file diff --git a/map2loop/sampler.py b/map2loop/sampler.py index 9d97d73d..d369fea5 100644 --- a/map2loop/sampler.py +++ b/map2loop/sampler.py @@ -1,11 +1,14 @@ +# internal imports +from .m2l_enums import Datatype +from .mapdata import MapData + +# external imports from abc import ABC, abstractmethod import beartype import geopandas import pandas import shapely import numpy -from .m2l_enums import Datatype -from .mapdata import MapData from typing import Optional @@ -84,6 +87,7 @@ def sample( data = spatial_data.copy() data["X"] = data.geometry.x data["Y"] = data.geometry.y + data["Z"] = map_data.get_value_from_raster_df(Datatype.DTM, data)["Z"] data["layerID"] = geopandas.sjoin( data, map_data.get_map_data(Datatype.GEOLOGY), how='left' )['index_right'] diff --git a/map2loop/thickness_calculator.py b/map2loop/thickness_calculator.py index af016800..dfc318b4 100644 --- a/map2loop/thickness_calculator.py +++ b/map2loop/thickness_calculator.py @@ -6,7 +6,19 @@ import geopandas from statistics import mean from .mapdata import MapData -from scipy.interpolate import Rbf +from .sample_storage import SampleSupervisor +from .interpolators import DipDipDirectionInterpolator +# internal imports +import scipy.interpolate +from abc import ABC, abstractmethod +import beartype +import numpy +from scipy.spatial.distance import euclidean +import pandas +import geopandas +from statistics import mean +from .mapdata import MapData +from .sample_storage import SampleSupervisor from .interpolators import DipDipDirectionInterpolator from .utils import ( create_points, @@ -15,8 +27,18 @@ multiline_to_line, find_segment_strike_from_pt, ) -from .m2l_enums import Datatype -from shapely.geometry import Point +from .m2l_enums import Datatype, SampleType +from .interpolators import DipDipDirectionInterpolator +from .mapdata import MapData + +# external imports +from abc import ABC, abstractmethod +import beartype +import numpy +import scipy +import pandas +import geopandas +from statistics import mean import shapely import math @@ -51,7 +73,7 @@ def compute( units: pandas.DataFrame, stratigraphic_order: list, basal_contacts: geopandas.GeoDataFrame, - structure_data: pandas.DataFrame, + samples: SampleSupervisor, map_data: MapData, ) -> pandas.DataFrame: """ @@ -93,7 +115,7 @@ def compute( units: pandas.DataFrame, stratigraphic_order: list, basal_contacts: geopandas.GeoDataFrame, - structure_data: pandas.DataFrame, + samples: SampleSupervisor, map_data: MapData, ) -> pandas.DataFrame: """ @@ -200,7 +222,7 @@ def compute( units: pandas.DataFrame, stratigraphic_order: list, basal_contacts: geopandas.GeoDataFrame, - structure_data: pandas.DataFrame, + samples: SampleSupervisor, map_data: MapData, ) -> pandas.DataFrame: """ @@ -242,9 +264,9 @@ def compute( # increase buffer around basal contacts to ensure that the points are included as intersections basal_contacts["geometry"] = basal_contacts["geometry"].buffer(0.01) # get the sampled contacts - contacts = geopandas.GeoDataFrame(map_data.sampled_contacts) + contacts = geopandas.GeoDataFrame(samples(SampleType.CONTACT)) # build points from x and y coordinates - geometry2 = contacts.apply(lambda row: Point(row.X, row.Y), axis=1) + geometry2 = geopandas.points_from_xy(contacts['X'], contacts['Y']) contacts.set_geometry(geometry2, inplace=True) # set the crs of the contacts to the crs of the units @@ -253,15 +275,16 @@ def compute( contacts = map_data.get_value_from_raster_df(Datatype.DTM, contacts) # update the geometry of the contact points to include the Z value contacts["geometry"] = contacts.apply( - lambda row: Point(row.geometry.x, row.geometry.y, row["Z"]), axis=1 + lambda row: shapely.Point(row.geometry.x, row.geometry.y, row["Z"]), axis=1 ) # spatial join the contact points with the basal contacts to get the unit for each contact point contacts = contacts.sjoin(basal_contacts, how="inner", predicate="intersects") contacts = contacts[["X", "Y", "Z", "geometry", "basal_unit"]].copy() bounding_box = map_data.get_bounding_box() + structural_data = samples(SampleType.STRUCTURE) # Interpolate the dip of the contacts interpolator = DipDipDirectionInterpolator(data_type="dip") - dip = interpolator(bounding_box, structure_data, interpolator=Rbf) + dip = interpolator(bounding_box, structure_data, interpolator=scipy.interpolate.Rbf) # create a GeoDataFrame of the interpolated orientations interpolated_orientations = geopandas.GeoDataFrame() # add the dip and dip direction to the GeoDataFrame @@ -279,7 +302,7 @@ def compute( interpolated = map_data.get_value_from_raster_df(Datatype.DTM, interpolated_orientations) # update the geometry of the interpolated points to include the Z value interpolated["geometry"] = interpolated.apply( - lambda row: Point(row.geometry.x, row.geometry.y, row["Z"]), axis=1 + lambda row: shapely.Point(row.geometry.x, row.geometry.y, row["Z"]), axis=1 ) # for each interpolated point, assign name of unit using spatial join units = map_data.get_map_data(Datatype.GEOLOGY) @@ -337,7 +360,7 @@ def compute( # get the elevation Z of the end point p2 p2[2] = map_data.get_value_from_raster(Datatype.DTM, p2[0], p2[1]) # calculate the length of the shortest line - line_length = euclidean(p1, p2) + line_length = scipy.spatial.distance.euclidean(p1, p2) # find the indices of the points that are within 5% of the length of the shortest line indices = shapely.dwithin(short_line, interp_points, line_length * 0.25) # get the dip of the points that are within @@ -399,7 +422,7 @@ def compute( units: pandas.DataFrame, stratigraphic_order: list, basal_contacts: geopandas.GeoDataFrame, - structure_data: pandas.DataFrame, + samples: SampleSupervisor, map_data: MapData, ) -> pandas.DataFrame: """ @@ -437,7 +460,7 @@ def compute( For the bottom and top units of the stratigraphic sequence, the assigned thickness will also be -1. """ # input sampled data - sampled_structures = structure_data + sampled_structures = samples(SampleType.STRUCTURE) basal_contacts = basal_contacts.copy() # grab geology polygons and calculate bounding boxes for each lithology @@ -455,7 +478,7 @@ def compute( # rebuild basal contacts lines based on sampled dataset sampled_basal_contacts = rebuild_sampled_basal_contacts( - basal_contacts, map_data.sampled_contacts + basal_contacts, samples(SampleType.CONTACT) ) # calculate map dimensions diff --git a/map2loop/utils.py b/map2loop/utils.py index f71941dc..b48ba36a 100644 --- a/map2loop/utils.py +++ b/map2loop/utils.py @@ -5,6 +5,7 @@ import beartype from typing import Union import pandas +import random @beartype.beartype @@ -116,7 +117,7 @@ def normal_vector_to_dipdirection_dip(normal_vector: numpy.ndarray) -> numpy.nda @beartype.beartype -def create_points(xy: Union[list, tuple, numpy.ndarray]): +def create_points(xy: Union[list, tuple, numpy.ndarray]) -> numpy.ndarray: """ Creates a list of shapely Point objects from a list, tuple, or numpy array of coordinates. @@ -300,3 +301,75 @@ def rebuild_sampled_basal_contacts( ) return sampled_basal_contacts + + +@beartype.beartype +def generate_random_hex_colors(n: int, seed: int = None) -> list: + """ + Generate a list of unique random hex color codes. + + Args: + n (int): The number of random hex color codes to generate. + + Returns: + list: A list of randomly generated hex color codes as strings. + + Example: + >>> generate_random_hex_colors(3) + ['#1a2b3c', '#4d5e6f', '#7f8e9d'] + """ + if not isinstance(n, int): + raise TypeError("n of colours must be an integer") ## not sure if necessary as beartype should handle this + + if seed is not None: + rng = numpy.random.default_rng(seed) + else: + rng = numpy.random.default_rng(123456) + + colors = set() # set prevents duplicates + + while len(colors) < n: + color = "#{:06x}".format(rng.integers(0, 0xFFFFFF)) + colors.add(color) + return list(colors) + + +@beartype.beartype +def hex_to_rgb(hex_color: str) -> tuple: + """ + Convert a hex color code to an RGBA tuple. + Args: + hex_color (str): The hex color code (e.g., "#RRGGBB" or "#RGB"). + alpha (float, optional): The alpha value (opacity) for the color. Defaults to 1.0. + Returns: + tuple: A tuple (r, g, b, a) where r, g, b are in the range 0-1 and a is in the range 0-1. + """ + # if input not string or starts with '#', raise error + if not isinstance(hex_color, str) or not hex_color.startswith('#'): + raise ValueError("Invalid hex color code. Must start with '#'.") + + # Remove '#' from the hex color code + hex_color = hex_color.lstrip('#') + + # check if hex color code is the right length + if len(hex_color) not in [3, 6]: + raise ValueError("Invalid hex color code. Must be 3 or 6 characters long after '#'.") + + # Handle short hex code (e.g., "#RGB") + if len(hex_color) == 3: + hex_color = ''.join([c * 2 for c in hex_color]) + + alpha = 1.0 + # Convert the hex color code to an RGBA tuple// if it fails, return error + try: + r = int(hex_color[0:2], 16) / 255.0 + g = int(hex_color[2:4], 16) / 255.0 + b = int(hex_color[4:6], 16) / 255.0 + + except ValueError as e: + raise ValueError( + "Invalid hex color code. Contains non-hexadecimal characters." + ) from e + + return (r, g, b, alpha) + diff --git a/map2loop/version.py b/map2loop/version.py index 1fe90f6a..a5761891 100644 --- a/map2loop/version.py +++ b/map2loop/version.py @@ -1 +1 @@ -__version__ = "3.1.4" +__version__ = "3.1.6" diff --git a/tests/mapdata/test_mapdata_assign_random_colours_to_units.py b/tests/mapdata/test_mapdata_assign_random_colours_to_units.py new file mode 100644 index 00000000..125caff8 --- /dev/null +++ b/tests/mapdata/test_mapdata_assign_random_colours_to_units.py @@ -0,0 +1,70 @@ +### This file tests the function colour_units() in map2loop/mapdata.py +### Two main test cases are considered: cases in which there is clut file and cases in which there is no clut file + +import pytest +import pandas as pd +from map2loop.mapdata import MapData + + +# are random colours being assigned to stratigraphic units in cases where no clut file is provided? +def test_colour_units_no_clut_file(): + # Create a sample DataFrame with missing 'colour' values + data = { + "name": ["Unit1", "Unit2", "Unit3"], + "colour": [None, None, None] + } + stratigraphic_units = pd.DataFrame(data) + + # Instantiate the class and call the method + md = MapData() + md.colour_filename = None # Ensure no file is used + result = md.colour_units(stratigraphic_units) + + # check that there are no duplicates in the 'unit' column + assert result['name'].is_unique, "colour_units() in mapdata.py producing duplicate units" + + # Check that the 'colour' column has been assigned random colors + assert len(result["colour"].dropna()) == 3, "function MapData.colour_units not assigning the right len of random colours" + + # are the generated colours valid hex colours? + colours = result["colour"] + + assert colours.apply(isinstance, args=(str,)).all(), "function MapData.colour_units without clut file not assigning random hex colours as str" + assert colours.str.startswith("#").all(), "function MapData.colour_units without clut file not generating the right hex codes with # at the start" + assert colours.str.len().isin([7, 4]).all(), "function MapData.colour_units without clut file not generating the right hex codes with 7 or 4 characters" + + +def test_colour_units_with_colour_file(tmp_path): + # Create a strati units df with missing 'colour' values + data = { + "name": ["Unit1", "Unit2", "Unit3"], + "colour": [None, None, None] + } + stratigraphic_units = pd.DataFrame(data) + + # Create a temp clut file + colour_data = { + "UNITNAME": ["Unit1", "Unit2"], + "colour": ["#112233", "#445566"] + } + colour_lookup_df = pd.DataFrame(colour_data) + colour_filename = tmp_path / "colour_lookup.csv" + colour_lookup_df.to_csv(colour_filename, index=False) + + # Instantiate the class and call the method + md = MapData() + md.colour_filename = str(colour_filename) + result = md.colour_units(stratigraphic_units) + + # check that there are no duplicates in the 'unit' column + assert result['name'].is_unique, "colour_units() in mapdata.py producing duplicate units" + + # Check that the 'colour' column has been merged correctly and missing colors are assigned + expected_colors = ["#112233", "#445566"] + assert result["colour"].iloc[0] == expected_colors[0], "function MapData.colour_units with clut file not assigning the right colour from the lookup file" + assert result["colour"].iloc[1] == expected_colors[1], "function MapData.colour_units with clut file not assigning the right colour from the lookup file" + assert isinstance(result["colour"].iloc[2], str) , "function MapData.colour_units with clut file not assigning random hex colours as str" + assert result["colour"].iloc[2].startswith("#"), "function MapData.colour_units with clut file not generating the right hex codes with # at the start" + assert len(result["colour"].iloc[2]) in {7,4}, "function MapData.colour_units with clut file not generating the right hex codes with 7 or 4 characters" + + diff --git a/tests/mapdata/test_mapdata_dipdir.py b/tests/mapdata/test_mapdata_dipdir.py new file mode 100644 index 00000000..68f45b47 --- /dev/null +++ b/tests/mapdata/test_mapdata_dipdir.py @@ -0,0 +1,54 @@ +### This file tests the function parse_structure_map() in map2loop/mapdata.py +### at the moment only tests for DIPDIR values lower than 360 degrees +### TODO: add more tests for this function + +import pytest +import geopandas +import shapely +from map2loop.mapdata import MapData +from map2loop.m2l_enums import Datatype + +def test_if_m2l_returns_all_sampled_structures_with_DIPDIR_lower_than_360(): + + # call the class + md = MapData() + + # add config definition + md.config.structure_config = { + "dipdir_column": "DIPDIR", + "dip_column": "DIP", + "description_column": "DESCRIPTION", + "bedding_text": "Bedding", + "objectid_column": "ID", + "overturned_column": "facing", + "overturned_text": "DOWN", + "orientation_type": "strike" + } + + # create mock data + data = { + 'geometry': [shapely.Point(1, 1), shapely.Point(2, 2), shapely.Point(3, 3),], + 'DIPDIR': [45.0, 370.0, 420.0], + 'DIP': [30.0, 60.0, 50], + 'OVERTURNED': ["False", "True", "True"], + 'DESCRIPTION': ["Bedding", "Bedding", "Bedidng"], + 'ID': [1, 2, 3] + } + + #build geodataframe to hold the data + data = geopandas.GeoDataFrame(data) + + # set it as the raw_data + md.raw_data[Datatype.STRUCTURE] = data + + # make it parse the structure map and raise exception if error in parse_structure_map + + try: + md.parse_structure_map() + except Exception as e: + pytest.fail(f"parse_structure_map raised an exception: {e}") + + # check if all values below 360 + assert md.data[Datatype.STRUCTURE]['DIPDIR'].all() < 360, "MapData.STRUCTURE is producing DIPDIRs > 360 degrees" + +# diff --git a/tests/project/test_plot_hamersley.py b/tests/project/test_plot_hamersley.py index 6300fcc4..027d285a 100644 --- a/tests/project/test_plot_hamersley.py +++ b/tests/project/test_plot_hamersley.py @@ -1,8 +1,14 @@ -import pytest +### This file tests the overall behavior of project.py. Runs from LoopServer. + +#internal imports from map2loop.project import Project from map2loop.m2l_enums import VerboseLevel + +#external imports +import pytest from pyproj.exceptions import CRSError import os +import requests bbox_3d = { "minx": 515687.31005864, @@ -23,22 +29,23 @@ def remove_LPF(): def test_project_execution(): - proj = Project( - use_australian_state_data="WA", - working_projection="EPSG:28350", - bounding_box=bbox_3d, - clut_file_legacy=False, - verbose_level=VerboseLevel.NONE, - loop_project_filename=loop_project_filename, - overwrite_loopprojectfile=True, - ) + try: + proj = Project( + use_australian_state_data="WA", + working_projection="EPSG:28350", + bounding_box=bbox_3d, + clut_file_legacy=False, + verbose_level=VerboseLevel.NONE, + loop_project_filename=loop_project_filename, + overwrite_loopprojectfile=True, + ) + except requests.exceptions.ReadTimeout: + pytest.skip("Connection to the server timed out, skipping test") + proj.run_all(take_best=True) assert proj is not None, "Plot Hamersley Basin failed to execute" - -def test_file_creation(): - expected_file = loop_project_filename - assert os.path.exists(expected_file), f"Expected file {expected_file} was not created" + assert os.path.exists(loop_project_filename), f"Expected file {loop_project_filename} was not created" ################################################################### diff --git a/tests/sample_storage/test_sample_storage.py b/tests/sample_storage/test_sample_storage.py new file mode 100644 index 00000000..a22ecc89 --- /dev/null +++ b/tests/sample_storage/test_sample_storage.py @@ -0,0 +1,203 @@ +import pytest +import os +from map2loop.project import Project +from map2loop.m2l_enums import VerboseLevel, SampleType, StateType, Datatype +from map2loop.sampler import SamplerSpacing, SamplerDecimator +from map2loop.sorter import SorterAlpha +import pandas + + +""" +============================ +Hamersley, Western Australia +============================ +""" + +#################################################################### +# Set the region of interest for the project +# ------------------------------------------- +# Define the bounding box for the ROI + +bbox_3d = { + "minx": 515687.31005864, + "miny": 7493446.76593407, + "maxx": 562666.860106543, + "maxy": 7521273.57407786, + "base": -3200, + "top": 3000, +} + +@pytest.fixture +def sample_supervisor(): + # Specify minimum details (which Australian state, projection and bounding box + # and output file) + loop_project_filename = "wa_output.loop3d" + proj = Project( + use_australian_state_data="WA", + working_projection="EPSG:28350", + bounding_box=bbox_3d, + verbose_level=VerboseLevel.NONE, + loop_project_filename=loop_project_filename, + overwrite_loopprojectfile=True, + ) + + # Set the distance between sample points for arial and linestring geometry + proj.sample_supervisor.set_sampler(SampleType.GEOLOGY, SamplerSpacing(200.0)) + proj.sample_supervisor.set_sampler(SampleType.FAULT, SamplerSpacing(200.0)) + + # Choose which stratigraphic sorter to use or run_all with "take_best" flag to run them all + proj.set_sorter(SorterAlpha()) + # proj.set_sorter(SorterAgeBased()) + # proj.set_sorter(SorterUseHint()) + # proj.set_sorter(SorterUseNetworkx()) + # proj.set_sorter(SorterMaximiseContacts()) + # proj.set_sorter(SorterObservationProjections()) + proj.run_all(take_best=True) + + return proj.sample_supervisor + + +def test_type(sample_supervisor): + assert sample_supervisor.type() == "SampleSupervisor" + + +def test_set_default_samplers(sample_supervisor): + sample_supervisor.set_default_samplers() + assert isinstance(sample_supervisor.samplers[SampleType.GEOLOGY], SamplerSpacing) + assert isinstance(sample_supervisor.samplers[SampleType.FAULT], SamplerSpacing) + assert isinstance(sample_supervisor.samplers[SampleType.FOLD], SamplerSpacing) + assert isinstance(sample_supervisor.samplers[SampleType.FAULT_ORIENTATION], SamplerDecimator) + assert isinstance(sample_supervisor.samplers[SampleType.DTM], SamplerSpacing) + assert isinstance(sample_supervisor.samplers[SampleType.CONTACT], SamplerSpacing) + assert isinstance(sample_supervisor.samplers[SampleType.STRUCTURE], SamplerDecimator) + + +def test_set_sampler(sample_supervisor): + sampler = SamplerSpacing(100.0) + sample_supervisor.set_sampler(SampleType.STRUCTURE, sampler) + assert sample_supervisor.samplers[SampleType.STRUCTURE] == sampler + assert sample_supervisor.sampler_dirtyflags[SampleType.STRUCTURE] is True + + +def test_get_sampler(sample_supervisor): + sampler = SamplerSpacing(100.0) + sample_supervisor.set_sampler(SampleType.STRUCTURE, sampler) + assert sample_supervisor.get_sampler(SampleType.STRUCTURE) == sampler.sampler_label + + +def test_store(sample_supervisor): + data = pandas.DataFrame() + sample_supervisor.store(SampleType.STRUCTURE, data) + assert isinstance(sample_supervisor.samples[SampleType.STRUCTURE], pandas.DataFrame) + sample_supervisor.store(SampleType.GEOLOGY, data) + assert isinstance(sample_supervisor.samples[SampleType.GEOLOGY], pandas.DataFrame) + sample_supervisor.store(SampleType.FAULT, data) + assert isinstance(sample_supervisor.samples[SampleType.FAULT], pandas.DataFrame) + sample_supervisor.store(SampleType.FOLD, data) + assert isinstance(sample_supervisor.samples[SampleType.FOLD], pandas.DataFrame) + sample_supervisor.store(SampleType.FAULT_ORIENTATION, data) + assert isinstance(sample_supervisor.samples[SampleType.FAULT_ORIENTATION], pandas.DataFrame) + + +def test_check_state(sample_supervisor): + sample_supervisor.check_state(SampleType.STRUCTURE) + assert sample_supervisor.dirtyflags[StateType.DATA] is False + assert sample_supervisor.dirtyflags[StateType.SAMPLER] is False + + +def test_load(sample_supervisor): + sample_supervisor.load(SampleType.STRUCTURE) + sample_supervisor.check_state(SampleType.STRUCTURE) + assert sample_supervisor.dirtyflags[StateType.DATA] is False + sample_supervisor.load(SampleType.GEOLOGY) + sample_supervisor.check_state(SampleType.GEOLOGY) + assert sample_supervisor.dirtyflags[StateType.DATA] is False + sample_supervisor.load(SampleType.FAULT) + sample_supervisor.check_state(SampleType.FAULT) + assert sample_supervisor.dirtyflags[StateType.DATA] is False + sample_supervisor.load(SampleType.FOLD) + sample_supervisor.check_state(SampleType.FOLD) + assert sample_supervisor.dirtyflags[StateType.DATA] is False + sample_supervisor.load(SampleType.FAULT_ORIENTATION) + sample_supervisor.check_state(SampleType.FAULT_ORIENTATION) + assert sample_supervisor.dirtyflags[StateType.DATA] is False + + +def test_process(sample_supervisor): + sample_supervisor.process(SampleType.STRUCTURE) + sample_supervisor.check_state(SampleType.STRUCTURE) + assert sample_supervisor.samples[SampleType.STRUCTURE] is not None + sample_supervisor.process(SampleType.GEOLOGY) + sample_supervisor.check_state(SampleType.GEOLOGY) + assert sample_supervisor.samples[SampleType.GEOLOGY] is not None + sample_supervisor.process(SampleType.FAULT) + sample_supervisor.check_state(SampleType.FAULT) + assert sample_supervisor.samples[SampleType.FAULT] is not None + sample_supervisor.process(SampleType.FOLD) + sample_supervisor.check_state(SampleType.FOLD) + assert sample_supervisor.samples[SampleType.FOLD] is not None + sample_supervisor.process(SampleType.CONTACT) + sample_supervisor.check_state(SampleType.CONTACT) + assert sample_supervisor.samples[SampleType.CONTACT] is not None + + +def test_reprocess(sample_supervisor): + + sample_supervisor.sampler_dirtyflags[SampleType.STRUCTURE] = True + sample_supervisor.reprocess(SampleType.STRUCTURE) + assert sample_supervisor.samples[SampleType.STRUCTURE] is not None + assert isinstance(sample_supervisor.samples[SampleType.STRUCTURE], pandas.DataFrame) + assert sample_supervisor.sampler_dirtyflags[SampleType.STRUCTURE] is False + + sample_supervisor.sampler_dirtyflags[SampleType.GEOLOGY] = True + sample_supervisor.reprocess(SampleType.GEOLOGY) + assert sample_supervisor.samples[SampleType.GEOLOGY] is not None + assert isinstance(sample_supervisor.samples[SampleType.GEOLOGY], pandas.DataFrame) + assert sample_supervisor.sampler_dirtyflags[SampleType.GEOLOGY] is False + + sample_supervisor.sampler_dirtyflags[SampleType.FOLD] = True + sample_supervisor.reprocess(SampleType.FOLD) + assert sample_supervisor.samples[SampleType.FOLD] is not None + assert isinstance(sample_supervisor.samples[SampleType.FOLD], pandas.DataFrame) + assert sample_supervisor.sampler_dirtyflags[SampleType.FOLD] is False + + sample_supervisor.sampler_dirtyflags[SampleType.CONTACT] = True + sample_supervisor.reprocess(SampleType.CONTACT) + assert sample_supervisor.samples[SampleType.CONTACT] is not None + assert isinstance(sample_supervisor.samples[SampleType.CONTACT], pandas.DataFrame) + assert sample_supervisor.sampler_dirtyflags[SampleType.CONTACT] is False + + +def test_call(sample_supervisor): + + sample_supervisor(SampleType.STRUCTURE) + assert sample_supervisor.samples[SampleType.STRUCTURE] is not None + assert isinstance(sample_supervisor.samples[SampleType.STRUCTURE], pandas.DataFrame) + + sample_supervisor.samples[SampleType.STRUCTURE] = None + sample_supervisor(SampleType.STRUCTURE) + assert sample_supervisor.samples[SampleType.STRUCTURE] is not None + assert isinstance(sample_supervisor.samples[SampleType.STRUCTURE], pandas.DataFrame) + + sample_supervisor.set_sampler(SampleType.STRUCTURE, SamplerDecimator(1)) + sample_supervisor(SampleType.STRUCTURE) + assert sample_supervisor.samples[SampleType.STRUCTURE] is not None + assert isinstance(sample_supervisor.samples[SampleType.STRUCTURE], pandas.DataFrame) + + sample_supervisor.samples[SampleType.GEOLOGY] = None + sample_supervisor.map_data.dirtyflags[SampleType.GEOLOGY] = True + sample_supervisor(SampleType.GEOLOGY) + assert sample_supervisor.samples[SampleType.GEOLOGY] is not None + assert isinstance(sample_supervisor.samples[SampleType.GEOLOGY], pandas.DataFrame) + + sample_supervisor(SampleType.FAULT) + assert sample_supervisor.samples[SampleType.FAULT] is not None + assert isinstance(sample_supervisor.samples[SampleType.FAULT], pandas.DataFrame) + + sample_supervisor(SampleType.FOLD) + assert sample_supervisor.samples[SampleType.FOLD] is not None + assert isinstance(sample_supervisor.samples[SampleType.FOLD], pandas.DataFrame) + + sample_supervisor(SampleType.CONTACT) + assert sample_supervisor.samples[SampleType.CONTACT] is not None + assert isinstance(sample_supervisor.samples[SampleType.CONTACT], pandas.DataFrame) \ No newline at end of file diff --git a/tests/thickness/InterpolatedStructure/test_interpolated_structure.py b/tests/thickness/InterpolatedStructure/test_interpolated_structure.py index 606556fc..ae0268ad 100644 --- a/tests/thickness/InterpolatedStructure/test_interpolated_structure.py +++ b/tests/thickness/InterpolatedStructure/test_interpolated_structure.py @@ -1,3 +1,10 @@ +# This test runs on a portion of the dataset in https://github.com/Loop3D/m2l3_examples/tree/main/Laurent2016_V2_variable_thicknesses (only for lithologies E, F, and G) +# structures are confined to litho_F, and the test confirms if the StructuralPoint thickness is calculated, for all lithologies, if the thickness is correct for F (~90 m), and top/bottom units are assigned -1 +# this creates a temp folder in Appdata to store the data to run the proj, checks the thickness, and then deletes the temp folder +# this was done to avoid overflow of file creation in the tests folder + +### This file tests the function InterpolatedStructure thickness calculator + import pytest import pandas as pd from map2loop.thickness_calculator import InterpolatedStructure @@ -9,7 +16,7 @@ import tempfile import pathlib from map2loop.sampler import SamplerSpacing, SamplerDecimator -from map2loop.m2l_enums import Datatype +from map2loop.m2l_enums import Datatype, SampleType import map2loop @@ -216,8 +223,8 @@ def project(): column = ['Litho_G', 'Litho_F', 'Litho_E'] - proj.set_sampler(Datatype.GEOLOGY, SamplerSpacing(100.0)) - proj.set_sampler(Datatype.STRUCTURE, SamplerDecimator(0)) + proj.sample_supervisor.set_sampler(SampleType.GEOLOGY, SamplerSpacing(100.0)) + proj.sample_supervisor.set_sampler(SampleType.STRUCTURE, SamplerDecimator(0)) proj.run_all(user_defined_stratigraphic_column=column) return proj @@ -245,7 +252,7 @@ def basal_contacts(project): @pytest.fixture def samples(project): - return project.structure_samples + return project.sample_supervisor @pytest.fixture diff --git a/tests/thickness/StructurePoint/test_thickness_StructuralPoint_local_source.py b/tests/thickness/StructurePoint/test_thickness_StructuralPoint_local_source.py index 21eae570..fbca23ca 100644 --- a/tests/thickness/StructurePoint/test_thickness_StructuralPoint_local_source.py +++ b/tests/thickness/StructurePoint/test_thickness_StructuralPoint_local_source.py @@ -3,6 +3,8 @@ # this creates a temp folder in Appdata to store the data to run the proj, checks the thickness, and then deletes the temp folder # this was done to avoid overflow of file creation in the tests folder +# this tests the thickness calculator Structural Point from local source + from map2loop.thickness_calculator import StructuralPoint from osgeo import gdal, osr import os @@ -12,7 +14,7 @@ import pathlib from map2loop.sampler import SamplerSpacing, SamplerDecimator from map2loop.project import Project -from map2loop.m2l_enums import Datatype +from map2loop.m2l_enums import SampleType, Datatype import map2loop @@ -215,8 +217,8 @@ def create_raster(output_path, bbox, epsg, pixel_size, value=100): proj.set_thickness_calculator(StructuralPoint()) column = ['Litho_G', 'Litho_F', 'Litho_E'] -proj.set_sampler(Datatype.GEOLOGY, SamplerSpacing(100.0)) -proj.set_sampler(Datatype.STRUCTURE, SamplerDecimator(0)) +proj.sample_supervisor.set_sampler(SampleType.GEOLOGY, SamplerSpacing(100.0)) +proj.sample_supervisor.set_sampler(SampleType.STRUCTURE, SamplerDecimator(0)) proj.run_all(user_defined_stratigraphic_column=column) diff --git a/tests/thickness/StructurePoint/test_thickness_StructuralPoint_server.py b/tests/thickness/StructurePoint/test_thickness_StructuralPoint_server.py index 8eb0e4bc..3cadeb3d 100644 --- a/tests/thickness/StructurePoint/test_thickness_StructuralPoint_server.py +++ b/tests/thickness/StructurePoint/test_thickness_StructuralPoint_server.py @@ -1,8 +1,19 @@ +# This test runs on a portion of the dataset in https://github.com/Loop3D/m2l3_examples/tree/main/Laurent2016_V2_variable_thicknesses (only for lithologies E, F, and G) +# structures are confined to litho_F, and the test confirms if the StructuralPoint thickness is calculated, for all lithologies, if the thickness is correct for F (~90 m), and top/bottom units are assigned -1 +# this creates a temp folder in Appdata to store the data to run the proj, checks the thickness, and then deletes the temp folder +# this was done to avoid overflow of file creation in the tests folder + +# this tests the thickness calculator Structural Point from a server + +#internal imports from map2loop.thickness_calculator import StructuralPoint from map2loop.project import Project from map2loop.m2l_enums import VerboseLevel -import pathlib +#external imports +import pathlib +import pytest +import requests import map2loop @@ -19,23 +30,26 @@ def test_from_aus_state(): loop_project_filename = "wa_output.loop3d" module_path = map2loop.__file__.replace("__init__.py", "") - proj = Project( - use_australian_state_data="WA", - working_projection="EPSG:28350", - bounding_box=bbox_3d, - config_filename=pathlib.Path(module_path) - / pathlib.Path('_datasets') - / pathlib.Path('config_files') - / pathlib.Path('WA.json'), - clut_filename=pathlib.Path(module_path) - / pathlib.Path('_datasets') - / pathlib.Path('clut_files') - / pathlib.Path('WA_clut.csv'), - # clut_file_legacy=False, - verbose_level=VerboseLevel.NONE, - loop_project_filename=loop_project_filename, - overwrite_loopprojectfile=True, - ) + try: + proj = Project( + use_australian_state_data="WA", + working_projection="EPSG:28350", + bounding_box=bbox_3d, + config_filename=pathlib.Path(module_path) + / pathlib.Path('_datasets') + / pathlib.Path('config_files') + / pathlib.Path('WA.json'), + clut_filename=pathlib.Path(module_path) + / pathlib.Path('_datasets') + / pathlib.Path('clut_files') + / pathlib.Path('WA_clut.csv'), + # clut_file_legacy=False, + verbose_level=VerboseLevel.NONE, + loop_project_filename=loop_project_filename, + overwrite_loopprojectfile=True, + ) + except requests.exceptions.ReadTimeout: + pytest.skip("Connection to the server timed out, skipping test") proj.set_thickness_calculator(StructuralPoint()) proj.run_all() diff --git a/tests/utils/test_rgb_and_hex_functions.py b/tests/utils/test_rgb_and_hex_functions.py new file mode 100644 index 00000000..cce2059b --- /dev/null +++ b/tests/utils/test_rgb_and_hex_functions.py @@ -0,0 +1,36 @@ +### This file tests the function generate_random_hex_colors() and hex_to_rgba() in map2loop/utils.py + +import pytest +import re +from map2loop.utils import generate_random_hex_colors, hex_to_rgb + +#does it return the right number of colors? +def test_generate_random_hex_colors_length(): + n = 5 + colors = generate_random_hex_colors(n) + assert len(colors) == n, f"utils function generate_random_hex_colors not returning the right number of hex codes.Expected {n} colors, got {len(colors)}" + +# are the returned hex strings the right format? +def test_generate_random_hex_colors_format(): + n = 10 + hex_pattern = re.compile(r'^#[0-9A-Fa-f]{6}$') + colors = generate_random_hex_colors(n) + for color in colors: + assert hex_pattern.match(color), f"utils function generate_random_hex_colors not returning hex strings in the right format. Got {color} instead." + +# is hex conversion to rgba working as expected? +def test_hex_to_rgba_long_hex(): + hex_color = "#1a2b3c" # long hex versions + expected_output = (0.10196078431372549, 0.16862745098039217, 0.23529411764705882, 1.0) + assert hex_to_rgb(hex_color) == expected_output, f"utils function hex_to_rgba not doing hex to rgba conversion correctly. Expected {expected_output}, got {hex_to_rgb(hex_color)}" + + +def test_hex_to_rgba_short_hex(): + hex_color = "#abc" # short hex versions + expected_output = (0.6666666666666666, 0.7333333333333333, 0.8, 1.0) + assert hex_to_rgb(hex_color) == expected_output + +# does it handle invalid inputs correctly? +def test_hex_to_rgba_invalid_hex(): + with pytest.raises(ValueError): + hex_to_rgb("12FF456"), "utils function hex_to_rgba is expected to raise a ValueError when an invalid hex string is passed, but it did not." \ No newline at end of file