diff --git a/.gitignore b/.gitignore index ee4d8f40..1783d563 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ configPP.py configPPplots.py configPPinduct.py configPPtrajec.py +configPPmontecarlo.py */PP*.py *~ .DS_Store diff --git a/PlanetProfile/BuildTest.py b/PlanetProfile/BuildTest.py index 3e55a24d..4a551ea6 100644 --- a/PlanetProfile/BuildTest.py +++ b/PlanetProfile/BuildTest.py @@ -11,10 +11,10 @@ import importlib, os, fnmatch, sys, time from copy import deepcopy from PlanetProfile import _Test, _TestImport -from PlanetProfile.GetConfig import Params as configParams +from PlanetProfile.GetConfig import Params as configParams, FigMisc from PlanetProfile.Main import PlanetProfile, InductOgram, ReloadInductOgram, ExploreOgram, ReloadExploreOgram -from PlanetProfile.Plotting.ProfilePlots import PlotExploreOgram, PlotExploreOgramDsigma -from PlanetProfile.Plotting.MagPlots import PlotInductOgram +from PlanetProfile.Plotting.ExplorationPlots import GenerateExplorationPlots, PlotExploreOgramMultiSubplot +from PlanetProfile.Plotting.MagPlots import GenerateMagPlots, GenerateExplorationMagPlots from PlanetProfile.Test.TestBayes import TestBayes # Include timestamps in messages and force debug level logging for all testing @@ -31,21 +31,13 @@ def full(iTestStart=2, skipType=None): + testBase = f'{_TestImport}.PPTest' # Set general testing config atop standard config options Params = configParams - Params.CALC_NEW = True - Params.CALC_NEW_REF = True - Params.CALC_NEW_INDUCT = True - Params.CALC_SEISMIC = True - Params.CALC_CONDUCT = True - Params.DO_PARALLEL = False - Params.RUN_ALL_PROFILES = False - Params.COMPARE = False - Params.NO_SAVEFILE = False - Params.DO_INDUCTOGRAM = False - Params.SKIP_INDUCTION = False + Params = setFullSettings(Params) + # Get total number of test files to run fList = fnmatch.filter(os.listdir(_Test), 'PPTest*') @@ -81,6 +73,8 @@ def full(iTestStart=2, skipType=None): Params.CALC_NEW = False Params.CALC_NEW_REF = False Params.CALC_NEW_INDUCT = False + Params.CALC_NEW_GRAVITY = False + Params.CALC_NEW_ASYM = False TestPlanets = np.append(TestPlanets, PlanetProfile(deepcopy(testPlanetN), Params)[0]) TestPlanets[-1].saveLabel += ' RELOAD' tMarks = np.append(tMarks, time.time()) @@ -88,29 +82,36 @@ def full(iTestStart=2, skipType=None): Params.CALC_NEW = True Params.CALC_NEW_REF = True Params.CALC_NEW_INDUCT = True + Params.CALC_NEW_GRAVITY = True + Params.CALC_NEW_ASYM = False testPlanet1.name = 'Test0' # Test that we can successfully run standard profiles with parallelization options Params.DO_PARALLEL = True TestPlanets = np.append(TestPlanets, PlanetProfile(deepcopy(testPlanet1), Params)[0]) - TestPlanets[-1].saveLabel += ' NO_PARALLEL' + TestPlanets[-1].saveLabel += ' PARALLEL' tMarks = np.append(tMarks, time.time()) # Make sure our auxiliary calculation flags work correctly Params.CALC_SEISMIC = False Params.CALC_CONDUCT = False + Params.CALC_VISCOSITY = False + Params.CALC_OCEAN_PROPS = False TestPlanets = np.append(TestPlanets, PlanetProfile(deepcopy(testPlanet1), Params)[0]) - TestPlanets[-1].saveLabel += ' NO_SEISMIC_OR_CONDUCT' + TestPlanets[-1].saveLabel += ' NO_SEISMIC_OR_CONDUCT_OR_VISCOSITY' tMarks = np.append(tMarks, time.time()) Params.CALC_SEISMIC = True Params.CALC_CONDUCT = True - + Params.CALC_VISCOSITY = True + Params.CALC_OCEAN_PROPS = True + """ Bayes testing disabled for now if skipType is None or skipType.lower() == 'bayes': # Test Bayesian analysis UpdateRun capabilities PlanetBayes, _ = TestBayes('Test') PlanetBayes.saveLabel = 'Bayes' TestPlanets = np.append(TestPlanets, PlanetBayes) tMarks = np.append(tMarks, time.time()) + """ # Check that skipping layers/portions works correctly Params.SKIP_INNER = True @@ -142,17 +143,22 @@ def full(iTestStart=2, skipType=None): def TestAllInductOgrams(TestPlanets, Params, tMarks): # Run all types of inductogram on Test7, with Test11 for porosity Params.DO_INDUCTOGRAM = True + Params.CALC_NEW_INDUCT = True + Params.CALC_NEW = True Params.NO_SAVEFILE = True Params.SKIP_INNER = True - for inductOtype in ['sigma', 'Tb', 'rho']: + for inductOtype in ['sigma', 'Tb', 'rho', 'oceanComp']: Params.Induct.inductOtype = inductOtype + # Test non-parallel and parallel processing Params.DO_PARALLEL = False + Params.PRELOAD_EOS = False _ = TestInductOgram(7, Params) Params.DO_PARALLEL = True + Params.PRELOAD_EOS = True Induction = TestInductOgram(7, Params) TestPlanets = np.append(TestPlanets, deepcopy(Induction)) tMarks = np.append(tMarks, time.time()) - + # Test reloading Induction = TestInductOgram(7, Params, CALC_NEW=False) TestPlanets = np.append(TestPlanets, deepcopy(Induction)) tMarks = np.append(tMarks, time.time()) @@ -160,8 +166,10 @@ def TestAllInductOgrams(TestPlanets, Params, tMarks): for inductOtype in ['phi']: Params.Induct.inductOtype = inductOtype Params.DO_PARALLEL = False + Params.PRELOAD_EOS = False _ = TestInductOgram(11, Params) Params.DO_PARALLEL = True + Params.PRELOAD_EOS = True Induction = TestInductOgram(11, Params) TestPlanets = np.append(TestPlanets, deepcopy(Induction)) tMarks = np.append(tMarks, time.time()) @@ -179,8 +187,15 @@ def TestAllInductOgrams(TestPlanets, Params, tMarks): def TestAllExploreOgrams(TestPlanets, Params, tMarks, SKIP_HYDRO=False): # Run all types of exploreogram on Test7, with Test5 for waterless Params.DO_EXPLOREOGRAM = True + Params.CALC_NEW = True Params.NO_SAVEFILE = True - Params.SKIP_INNER = True + Params.Explore.zName = 'CMR2mean' + allZNames = ["CMR2mean", "D_km", "Dconv_m", "dzIceI_km", "dzClath_km", "dzIceIII_km", "dzIceIIIund_km", + "dzIceV_km", "dzIceVund_km", "dzIceVI_km", "dzWetHPs_km", "eLid_km", "phiSeafloor_frac", "Rcore_km", "rhoSilMean_kgm3", "rhoCoreMean_kgm3", + "sigmaMean_Sm", "silPhiCalc_frac", "zb_km", "zSeafloor_km", + "hLoveAmp", "kLoveAmp", "lLoveAmp", "deltaLoveAmp", "hLovePhase", "kLovePhase", "lLovePhase", "deltaLovePhase", + 'InductionAmp', 'InductionPhase', 'InductionrBi1Tot_nT', 'InductioniBi1Tot_nT', 'InductionrBi1x_nT', 'InductionrBi1y_nT', + 'InductionrBi1z_nT', 'InductioniBi1x_nT', 'InductioniBi1y_nT', 'InductioniBi1z_nT'] hydroExploreBds = { 'xFeS': [0, 1], 'rhoSilInput_kgm3': [2000, 4500], @@ -193,8 +208,11 @@ def TestAllExploreOgrams(TestPlanets, Params, tMarks, SKIP_HYDRO=False): 'icePhi_frac': [0, 0.8], 'icePclosure_MPa': [5, 40], 'Htidal_Wm3': [1e-18, 1e-11], - 'Qrad_Wkg': [1e-20, 1e-14] + 'Qrad_Wkg': [1e-20, 1e-14], + 'oceanComp': [-12, -3], # Placeholder - range is determined by input range file data, + 'zb_approximate_km': [10, 90] } + waterlessExploreBds = { 'xFeS': [0, 1], 'rhoSilInput_kgm3': [2000, 4500], @@ -206,7 +224,7 @@ def TestAllExploreOgrams(TestPlanets, Params, tMarks, SKIP_HYDRO=False): 'Qrad_Wkg': [1e-20, 1e-14], 'qSurf_Wm2': [50e-3, 400e-3] } - + if not SKIP_HYDRO: log.info('Running exploreOgrams for icy bodies for all input types.') for xName in hydroExploreBds.keys(): @@ -217,8 +235,10 @@ def TestAllExploreOgrams(TestPlanets, Params, tMarks, SKIP_HYDRO=False): Params.Explore.xRange = hydroExploreBds[xName] Params.Explore.yRange = hydroExploreBds[yName] Params.DO_PARALLEL = False + Params.PRELOAD_EOS = False _ = TestExploreOgram(7, Params) Params.DO_PARALLEL = True + Params.PRELOAD_EOS = True Exploration = TestExploreOgram(7, Params) TestPlanets = np.append(TestPlanets, deepcopy(Exploration)) tMarks = np.append(tMarks, time.time()) @@ -226,8 +246,16 @@ def TestAllExploreOgrams(TestPlanets, Params, tMarks, SKIP_HYDRO=False): Exploration = TestExploreOgram(7, Params, CALC_NEW=False) TestPlanets = np.append(TestPlanets, deepcopy(Exploration)) tMarks = np.append(tMarks, time.time()) - + + log.info('Testing all zName options for exploreOgrams.') + Exploration = TestExploreOgram(7, Params, CALC_NEW=False) + # Now test the multi-subplot function if not already tested (dividing into groups of 10 to prevent errors in too big of file size) + for i in range(0, len(allZNames), 10): + Params.Explore.zName = allZNames[i:i+10] + Exploration = TestExploreOgram(7, Params, CALC_NEW=False) + log.info('Running exploreOgrams for waterless bodies for all input types.') + Params.Explore.zName = 'CMR2mean' for xName in waterlessExploreBds.keys(): for yName in waterlessExploreBds.keys(): if xName != yName: @@ -236,8 +264,10 @@ def TestAllExploreOgrams(TestPlanets, Params, tMarks, SKIP_HYDRO=False): Params.Explore.xRange = waterlessExploreBds[xName] Params.Explore.yRange = waterlessExploreBds[yName] Params.DO_PARALLEL = False + Params.PRELOAD_EOS = False _ = TestExploreOgram(5, Params) Params.DO_PARALLEL = True + Params.PRELOAD_EOS = True Exploration = TestExploreOgram(5, Params) TestPlanets = np.append(TestPlanets, deepcopy(Exploration)) tMarks = np.append(tMarks, time.time()) @@ -258,8 +288,8 @@ def TestInductOgram(testNum, Params, CALC_NEW=True): # Set sizes low so things don't take ages to run Params.Induct.nwPts, Params.Induct.nTbPts, Params.Induct.nphiPts, \ - Params.Induct.nrhoPts, Params.Induct.nSigmaPts, Params.Induct.nDpts \ - = (4 for _ in range(6)) + Params.Induct.nrhoPts, Params.Induct.nSigmaPts, Params.Induct.nDpts, Params.Induct.nOceanCompPts, Params.Induct.nZbPts \ + = (4 for _ in range(8)) if CALC_NEW: Induction, Params = InductOgram(testName, Params) @@ -267,7 +297,7 @@ def TestInductOgram(testNum, Params, CALC_NEW=True): else: Induction, _, Params = ReloadInductOgram(testName, Params) end = ' RELOAD' - PlotInductOgram(Induction, Params) + GenerateExplorationMagPlots([Induction], [Params.FigureFiles], Params) Induction.name = testName Induction.saveLabel = f'{Params.Induct.inductOtype} induct-o-gram{end}' @@ -282,20 +312,16 @@ def TestExploreOgram(testNum, Params, CALC_NEW=True): = (4 for _ in range(2)) if CALC_NEW: + Params.CALC_NEW = True Exploration, Params = ExploreOgram(testName, Params) end = '' else: + Params.CALC_NEW = False Exploration, Params = ReloadExploreOgram(testName, Params) end = ' RELOAD' - if isinstance(Params.Explore.zName, list): - figNames = Params.FigureFiles.explore + [] - for zName, figName in zip(Params.Explore.zName, figNames): - Exploration.zName = zName - Params.FigureFiles.explore = figName - PlotExploreOgram([Exploration], Params) - Params.FigureFiles.explore = figNames - PlotExploreOgramDsigma([Exploration], Params) + GenerateExplorationPlots([Exploration], [Params.FigureFiles], Params) + GenerateExplorationMagPlots([Exploration], [Params.FigureFiles], Params) Exploration.name = testName Exploration.saveLabel = f'{Params.Explore.xName} x {Params.Explore.yName} explore-o-gram{end}' @@ -307,19 +333,14 @@ def simple(iTests=None): # Set general testing config atop standard config options Params = configParams - Params.CALC_NEW = True - Params.CALC_NEW_REF = True - Params.CALC_NEW_INDUCT = True - Params.CALC_SEISMIC = True - Params.CALC_CONDUCT = True - Params.RUN_ALL_PROFILES = False - Params.COMPARE = False - Params.DO_INDUCTOGRAM = False - Params.DO_EXPLOREOGRAM = False - Params.DO_PARALLEL = False - Params.SKIP_INDUCTION = False + Params = setFullSettings(Params) tStart = time.time() + # Normalize iTests into a list + if isinstance(iTests, int): + iTests = [iTests] + else: + iTests = list(iTests) for iTest in iTests: bodyname = f'{testMod}{iTest}' testPlanet = importlib.import_module(bodyname).Planet @@ -330,6 +351,14 @@ def simple(iTests=None): TestExploreOgram(iTest, Params, CALC_NEW=Params.CALC_NEW_INDUCT) else: _ = PlanetProfile(testPlanet, Params) + # Verify that we can reload things as needed in each case + Params.CALC_NEW = False + Params.CALC_NEW_REF = False + Params.CALC_NEW_INDUCT = False + _ = PlanetProfile(testPlanet, Params) + Params.CALC_NEW = True + Params.CALC_NEW_REF = True + Params.CALC_NEW_INDUCT = True tEnd = time.time() log.info('Simple test complete!') @@ -337,12 +366,77 @@ def simple(iTests=None): return +def setFullSettings(Params): + Params.CALC_NEW = True + Params.CALC_NEW_REF = True + Params.CALC_NEW_INDUCT = True + Params.CALC_NEW_GRAVITY = True + Params.CALC_NEW_ASYM = False + Params.CALC_SEISMIC = True + Params.CALC_CONDUCT = True + Params.CALC_VISCOSITY = True + Params.CALC_OCEAN_PROPS = True + Params.CALC_ASYM = True + Params.DO_PARALLEL = False + Params.RUN_ALL_PROFILES = False + Params.COMPARE = False + Params.NO_SAVEFILE = False + Params.FORCE_EOS_RECALC = True + Params.SKIP_INNER = False + Params.DISP_LAYERS = True + Params.DISP_TABLE = True + Params.ALLOW_BROKEN_MODELS = False + Params.DEPRECATED = False + Params.TIME_AND_DATE_LABEL = False + # Set plotting options + Params.SKIP_PLOTS = False + Params.PLOT_GRAVITY = True + Params.PLOT_HYDROSPHERE = True + Params.PLOT_HYDROSPHERE_THERMODYNAMICS = True + Params.PLOT_MELTING_CURVES = True + Params.PLOT_SPECIES_HYDROSPHERE = True + Params.PLOT_REF = True + Params.PLOT_SIGS = True + Params.PLOT_SOUNDS = True + Params.PLOT_REF = True + Params.PLOT_SIGS = True + Params.PLOT_SOUNDS = True + Params.PLOT_TRADEOFF = True + Params.PLOT_POROSITY = True + Params.PLOT_SEISMIC = True + Params.PLOT_PRESSURE_DEPTH = True + Params.PLOT_VISCOSITY = True + Params.PLOT_WEDGE = True + Params.PLOT_HYDRO_PHASE = True + Params.PLOT_PVT_HYDRO = True + Params.PLOT_PVT_ISOTHERMAL_HYDRO = True + Params.PLOT_PVT_INNER = True + Params.PLOT_BDIP = True + Params.PLOT_BSURF = True + Params.PLOT_ASYM = True + Params.PLOT_TRAJECS = True + Params.PLOT_BINVERSION = True + Params.LEGEND = True + Params.TITLES = True + Params.PLOT_COMBO_EXPLORATIONS = True + FigMisc.propsToPlot = ['rho', 'Cp', 'alpha', 'VP', 'KS', 'sig', 'VS', 'GS'] + + # Disable other features not relevant to single model runs + Params.DO_INDUCTOGRAM = False + Params.DO_EXPLOREOGRAM = False + Params.DO_MONTECARLO = False + Params.SKIP_INDUCTION = False + Params.SKIP_GRAVITY = False + Params.PRELOAD_EOS = False + + return Params + if __name__ == '__main__': skipType = None if len(sys.argv) > 1: # Test type was passed as command line argument testType = sys.argv[1] - if len(sys.argv) > 2: + if len(sys.argv) == 3: if sys.argv[2].isdigit(): # Test profile number was passed as command line argument iTest = int(sys.argv[2]) @@ -350,6 +444,11 @@ def simple(iTests=None): # Skip to specific testing section was passed as command line arg iTest = None skipType = sys.argv[2] + elif len(sys.argv) > 3: + iTest = [] + for arg in sys.argv[2:]: + if arg.isdigit(): + iTest.append(int(arg)) else: iTest = None else: @@ -357,7 +456,7 @@ def simple(iTests=None): iTest = None if testType == 'simple': - simple([iTest]) + simple(iTest) elif testType == 'Bayes': _, _ = TestBayes('Test') else: diff --git a/PlanetProfile/CustomSolution/defaultConfigCustomSolution.py b/PlanetProfile/CustomSolution/defaultConfigCustomSolution.py index 3c14b10b..9e342b19 100644 --- a/PlanetProfile/CustomSolution/defaultConfigCustomSolution.py +++ b/PlanetProfile/CustomSolution/defaultConfigCustomSolution.py @@ -1,7 +1,7 @@ """ Default custom ocean solution settings """ from PlanetProfile.Utilities.defineStructs import CustomSolutionParamsStruct -configCustomSolutionVersion = 6 # Integer number for config file version. Increment when new settings are added to the default config file. +configCustomSolutionVersion = 7 # Integer number for config file version. Increment when new settings are added to the default config file. def customSolutionAssign(): CustomSolutionParams = CustomSolutionParamsStruct() @@ -22,6 +22,7 @@ def customSolutionAssign(): CustomSolutionParams.SOLID_PHASES = True # Only valid if SOLID_PHASES is True. Specify the Solid Phases to consider - specifying only primary phases can speed up runtime. 'All' considers all possible phases available in database CustomSolutionParams.SOLID_PHASES_TO_CONSIDER = ['Carbonates', 'Sulfates'] # Can specify minerals specifically, or add keywords that include 'Carbonates', 'Sulfates' that are defined in Constants.py + CustomSolutionParams.SOLID_PHASES_TO_SUPPRESS = None # Specify solid phases to suppress from equilibrium calculations. None suppresses none. Should be in a list format like ['Dolomite', 'Calcite'] # Have PlanetProfile remove species that Frezchem does not have in database so we can consider more diverse speciated compositions in the ocean thermodynamcis # This removes self-consistency between the phase equilibria (up to 200MPa) and ocean thermodynamics since frezchem is calculating liquid-IceI equilibria of a less speciated chemistry diff --git a/PlanetProfile/GetConfig.py b/PlanetProfile/GetConfig.py index 424a84c1..564cf9b3 100644 --- a/PlanetProfile/GetConfig.py +++ b/PlanetProfile/GetConfig.py @@ -7,147 +7,34 @@ import logging import multiprocessing as mtp from functools import partial, partialmethod -from PlanetProfile import _DefaultList +from PlanetProfile import _DefaultList, _ROOT +import importlib import MoonMag.symmetry_funcs, MoonMag.asymmetry_funcs -# Fetch version numbers first to warn user about compatibility -from PlanetProfile.defaultConfig import configVersion -from configPP import configVersion as userConfigVersion - -# Check config file versions and warn user if they differ -if configVersion != userConfigVersion: - warn(f'User configPP file is version {userConfigVersion}, but the default file is ' + - f'version {configVersion}. Some settings may be missing; default values will be used. ' + - 'To align the file version, delete configPP.py and run again, or execute reset.py ' + - 'with python -m PlanetProfile.reset') - -# Grab config settings first to load LSK for later adjustments with SPICE -from PlanetProfile.defaultConfig import configAssign -from configPP import configAssign as userConfigAssign +""" Get default config settings """ +from PlanetProfile.defaultConfig import configAssign, configVersion +from PlanetProfile.MagneticInduction.defaultConfigInduct import inductAssign, configInductVersion +from PlanetProfile.TrajecAnalysis.defaultConfigTrajec import trajecAssign, configTrajecVersion +from PlanetProfile.Plotting.defaultConfigPlots import plotAssign, configPlotsVersion +from PlanetProfile.CustomSolution.defaultConfigCustomSolution import customSolutionAssign, configCustomSolutionVersion +from PlanetProfile.Gravity.defaultConfigGravity import gravityAssign, configGravityVersion +from PlanetProfile.MonteCarlo.defaultConfigMonteCarlo import montecarloAssign, configMonteCarloVersion +from PlanetProfile.Inversion.defaultConfigInversion import inversionAssign, configInversionVersion Params, ExploreParams = configAssign() -userParams, userExploreParams = userConfigAssign() - -if hasattr(userParams, 'spiceTLS') and hasattr(userParams, 'spiceDir'): - userLSK = os.path.join(userParams.spiceDir, userParams.spiceTLS) - if not os.path.isfile(userLSK): - raise FileNotFoundError(f'Leapseconds kernel was not found at {userLSK}. This likely means PlanetProfile ' + - f'has not been fully installed. Run the install script with the following command:\n' + - f'python -m PlanetProfile.install') - spice.furnsh(userLSK) -else: - defLSK = os.path.join(Params.spiceDir, Params.spiceTLS) - if not os.path.isfile(defLSK): - raise FileNotFoundError(f'Leapseconds kernel was not found at {defLSK}. This likely means PlanetProfile ' + - f'has not been fully installed. Run the install script with the following command:\n' + - f'python -m PlanetProfile.install') - spice.furnsh(defLSK) - -from PlanetProfile.MagneticInduction.defaultConfigInduct import configInductVersion -from configPPinduct import configInductVersion as userConfigInductVersion -from PlanetProfile.Plotting.defaultConfigPlots import configPlotsVersion -from configPPplots import configPlotsVersion as userConfigPlotsVersion -from PlanetProfile.CustomSolution.defaultConfigCustomSolution import configCustomSolutionVersion -from configPPcustomsolution import configCustomSolutionVersion as userCustomSolutionVersion -from PlanetProfile.Gravity.defaultConfigGravity import configGravityVersion -from configPPgravity import configGravityVersion as userConfigGravityVersion -from PlanetProfile.Model.defaultConfigModel import configModelVersion -from configPPmodel import configModelVersion as userConfigModelVersion - -# Check sub-config file versions and warn user if they differ -if configInductVersion != userConfigInductVersion: - warn(f'User configPPinduct file is version {userConfigInductVersion}, but the default file is ' + - f'version {configInductVersion}. Some settings may be missing; default values will be used. ' + - f'To align the file version, delete configPPinduct.py and run again, or execute reset.py ' + - f'with python -m PlanetProfile.reset') -if configPlotsVersion != userConfigPlotsVersion: - warn(f'User configPPplots file is version {userConfigPlotsVersion}, but the default file is ' + - f'version {configPlotsVersion}. Some settings may be missing; default values will be used. ' + - f'To align the file version, delete configPPplots.py and run again, or execute reset.py ' + - f'with python -m PlanetProfile.reset') -if configCustomSolutionVersion != userCustomSolutionVersion: - warn(f'User configPPcustomsolution file is version {userCustomSolutionVersion}, but the default file is ' + - f'version {configCustomSolutionVersion}. Some settings may be missing; default values will be used. ' + - f'To align the file version, delete configPPcustomsolution.py and run again, or execute reset.py ' + - f'with python -m PlanetProfile.reset') -if configGravityVersion != userConfigGravityVersion: - warn(f'User configPPgravity file is version {userConfigGravityVersion}, but the default file is ' + - f'version {configGravityVersion}. Some settings may be missing; default values will be used. ' + - f'To align the file version, delete configPPgravity.py and run again, or execute reset.py ' + - f'with python -m PlanetProfile.reset') -if configModelVersion != userConfigModelVersion: - warn(f'User configPPmodel file is version {userConfigModelVersion}, but the default file is ' + - f'version {configModelVersion}. Some settings may be missing; default values will be used. ' + - f'To align the file version, delete configPPmodel.py and run again, or execute reset.py ' + - f'with python -m PlanetProfile.reset') - -from PlanetProfile.MagneticInduction.defaultConfigInduct import inductAssign -from configPPinduct import inductAssign as userInductAssign -from PlanetProfile.TrajecAnalysis.defaultConfigTrajec import trajecAssign -from configPPtrajec import trajecAssign as userTrajecAssign -from PlanetProfile.Plotting.defaultConfigPlots import plotAssign -from configPPplots import plotAssign as userPlotAssign -from PlanetProfile.CustomSolution.defaultConfigCustomSolution import customSolutionAssign -from configPPcustomsolution import customSolutionAssign as userCustomSolutionAssign -from PlanetProfile.Gravity.defaultConfigGravity import gravityAssign -from configPPgravity import gravityAssign as userGravityAssign -from PlanetProfile.Model.defaultConfigModel import modelAssign -from configPPmodel import modelAssign as userModelAssign - +defLSK = os.path.join(Params.spiceDir, Params.spiceTLS) +if not os.path.isfile(defLSK): + raise FileNotFoundError(f'Leapseconds kernel was not found at {defLSK}. This likely means PlanetProfile ' + + f'has not been fully installed. Run the install script with the following command:\n' + + f'python -m PlanetProfile.install') +spice.furnsh(defLSK) SigParams, ExcSpecParams, InductParams, _ = inductAssign() -userSigParams, userExcSpecParams, userInductParams, userTestBody = userInductAssign() TrajecParams = trajecAssign() -userTrajecParams = userTrajecAssign() -Color, Style, FigLbl, FigSize, FigMisc = plotAssign() -userColor, userStyle, userFigLbl, userFigSize, userFigMisc = userPlotAssign() CustomSolutionParams = customSolutionAssign() -userCustomSolutionParams = userCustomSolutionAssign() GravityParams = gravityAssign() -userGravityParams = userGravityAssign() -ModelParams = modelAssign() -userModelParams = userModelAssign() - -# Load user settings to allow for configuration -for attr, value in userParams.__dict__.items(): - setattr(Params, attr, value) -for attr, value in userExploreParams.__dict__.items(): - setattr(ExploreParams, attr, value) -for attr, value in userSigParams.__dict__.items(): - setattr(SigParams, attr, value) -for attr, value in userExcSpecParams.__dict__.items(): - setattr(ExcSpecParams, attr, value) -for attr, value in userInductParams.__dict__.items(): - setattr(InductParams, attr, value) -for attr, value in userGravityParams.__dict__.items(): - setattr(GravityParams, attr, value) -for attr, value in userModelParams.__dict__.items(): - setattr(ModelParams, attr, value) - -for attr, value in userTrajecParams.__dict__.items(): - setattr(TrajecParams, attr, value) - -for attr, value in userColor.__dict__.items(): - setattr(Color, attr, value) -for attr, value in userStyle.__dict__.items(): - setattr(Style, attr, value) -for attr, value in userFigLbl.__dict__.items(): - setattr(FigLbl, attr, value) -for attr, value in userFigSize.__dict__.items(): - setattr(FigSize, attr, value) -for attr, value in userFigMisc.__dict__.items(): - setattr(FigMisc, attr, value) -for attr, value in userCustomSolutionParams.__dict__.items(): - setattr(CustomSolutionParams, attr, value) - -# Execute necessary adjustments and add non-settings to Params objects -Params.tStart_s = time.time() - -# Get RefProfile file names from the lists set in config file(s) -Params.fNameRef = {comp:f'{comp}Ref.txt' for comp in Params.wRef_ppt.keys()} -# Initialize array dicts for refprofiles -Params.Pref_MPa = {} -Params.rhoRef_kgm3 = {} -Params.nRef = {} -Params.nRefPts = {} +MonteCarloParams = montecarloAssign() +InversionParams = inversionAssign() +Color, Style, FigLbl, FigSize, FigMisc = plotAssign() + # Create parallel printout log level logging.PROFILE = logging.WARN + 5 @@ -162,8 +49,15 @@ # Allow progress printout to be silenced if QUIET is selected Params.logParallel += 10 -# Set up message logging and apply verbosity level +# === Custom Log Level: PERFORMANCE === +logging.TIMING = logging.INFO + 5 +logging.addLevelName(logging.TIMING, 'TIMING') +logging.Logger.timing = partialmethod(logging.Logger.log, logging.TIMING) +logging.timing = partial(logging.log, logging.TIMING) + +# Set up message logging, child loggers, and apply verbosity level log = logging.getLogger('PlanetProfile') +timeLog = logging.getLogger('PlanetProfile.Timing') if Params.VERBOSE: logLevel = logging.DEBUG elif Params.QUIET: @@ -178,65 +72,231 @@ logLevelLBF = logging.ERROR else: logLevelLBF = logLevel +if Params.TIMING: + timingLogLevel = logging.TIMING +else: + timingLogLevel = logging.TIMING + 1 stream = logging.StreamHandler(sys.stdout) stream.setFormatter(logging.Formatter(Params.printFmt)) log.setLevel(logLevel) log.addHandler(stream) +timeLog.setLevel(timingLogLevel) +timeStream = logging.StreamHandler(sys.stdout) +timeStream.setFormatter(logging.Formatter(Params.printFmt)) +timeLog.addHandler(timeStream) +timeLog.propagate = False # Prevent double logging to parent handlers logging.getLogger('matplotlib').setLevel(logging.WARNING) logging.getLogger('PIL').setLevel(logging.WARNING) logging.getLogger('MoonMag').setLevel(logLevelMoonMag) logging.getLogger('lbftd').setLevel(logLevelLBF) log.debug('Printing verbose runtime messages. Toggle with Params.VERBOSE in configPP.py.') -# Parallel processing toggles -if Params.DO_PARALLEL: - Params.maxCores = mtp.cpu_count() -else: - Params.maxCores = 1 - log.info('DO_PARALLEL is False. Blocking parallel execution.') - -# Add Test body settings to InductParams -inductOgramAttr = ['wMin', 'wMax', 'TbMin', 'TbMax', 'phiMin', 'phiMax', 'rhoMin', - 'rhoMax', 'sigmaMin', 'sigmaMax', 'Dmin', 'Dmax', 'zbFixed_km'] -[getattr(InductParams, attr).update({'Test': getattr(InductParams, attr)[userTestBody]}) - for attr in inductOgramAttr] - -# Force calculations to be done for each oscillation to be plotted in inductograms -for osc in InductParams.excSelectionPlot: - if InductParams.excSelectionPlot[osc] and not InductParams.excSelectionCalc[osc]: - InductParams.excSelectionCalc[osc] = True - -# Assign missing values with defaults in inductOgram settings -Tnames = list(InductParams.cLevels['Test'].keys()) -zNames = ['Amp', 'Bx', 'By', 'Bz'] -for bodyname in _DefaultList: - for attr in inductOgramAttr: - if bodyname not in getattr(InductParams, attr).keys(): - getattr(InductParams, attr).update({'Test': getattr(InductParams, attr)[userTestBody]}) - - if bodyname not in InductParams.cLevels.keys(): - InductParams.cLevels[bodyname] = {} - for Tname in Tnames: - InductParams.cLevels[bodyname][Tname] = {zName: None for zName in zNames} - if bodyname not in InductParams.cfmt.keys(): - InductParams.cfmt[bodyname] = {} - for Tname in Tnames: - InductParams.cfmt[bodyname][Tname] = {zName: None for zName in zNames} - -# Assign extrapolation settings for Mixed Clathrates in dictionary -mixedClathExtrap = ['MixedClathrateIh', 'MixedClathrateII', 'MixedClathrateIII', 'MixedClathrateV', 'MixedClathrateVI'] -icePhase = ['Ih', 'II', 'III', 'V', 'VI'] -for i, phase in enumerate(mixedClathExtrap): - if phase not in Params.EXTRAP_ICE.keys(): - Params.EXTRAP_ICE[phase] = Params.EXTRAP_ICE['Clath'] and Params.EXTRAP_ICE[icePhase[i]] - -# Load sub-settings into Params so they can be passed around together -Params.Sig = SigParams -Params.Induct = InductParams -Params.MagSpectrum = ExcSpecParams -Params.Explore = ExploreParams -Params.Trajec = TrajecParams -Params.CustomSolution = CustomSolutionParams -Params.Gravity = GravityParams -Params.Model = ModelParams + +""" Load user settings to allow for configuration """ +def loadUserSettings(configModule: str = ''): + if configModule != '': + configModule = 'UserConfigs.' + configModule + else: + configModule = 'UserConfigs' + configPP = importlib.import_module(configModule + '.configPP') + userConfigAssign = configPP.configAssign + userParams, userExploreParams = userConfigAssign() + userConfigVersion = configPP.configVersion + # Check config file versions and warn user if they differ + if configVersion != userConfigVersion: + warn(f'User configPP file is version {userConfigVersion}, but the default file is ' + + f'version {configVersion}. Some settings may be missing; default values will be used. ' + + 'To align the file version, delete configPP.py and run again, or execute reset.py ' + + 'with python -m PlanetProfile.reset') + if hasattr(userParams, 'spiceTLS') and hasattr(userParams, 'spiceDir'): + userLSK = os.path.join(userParams.spiceDir, userParams.spiceTLS) + if not os.path.isfile(userLSK): + raise FileNotFoundError(f'Leapseconds kernel was not found at {userLSK}. This likely means PlanetProfile ' + + f'has not been fully installed. Run the install script with the following command:\n' + + f'python -m PlanetProfile.install') + spice.furnsh(userLSK) + else: + defLSK = os.path.join(Params.spiceDir, Params.spiceTLS) + if not os.path.isfile(defLSK): + raise FileNotFoundError(f'Leapseconds kernel was not found at {defLSK}. This likely means PlanetProfile ' + + f'has not been fully installed. Run the install script with the following command:\n' + + f'python -m PlanetProfile.install') + spice.furnsh(defLSK) + configInduct = importlib.import_module(configModule + '.configPPinduct') + userConfigInductVersion = configInduct.configInductVersion + configTrajec = importlib.import_module(configModule + '.configPPtrajec') + userConfigTrajecVersion = configTrajec.configTrajecVersion + configPlots = importlib.import_module(configModule + '.configPPplots') + userConfigPlotsVersion = configPlots.configPlotsVersion + configCustomSolution = importlib.import_module(configModule + '.configPPcustomsolution') + userConfigCustomSolutionVersion = configCustomSolution.configCustomSolutionVersion + configGravity = importlib.import_module(configModule + '.configPPgravity') + userConfigGravityVersion = configGravity.configGravityVersion + configMonteCarlo = importlib.import_module(configModule + '.configPPmontecarlo') + userConfigMonteCarloVersion = configMonteCarlo.configMonteCarloVersion + configInversion = importlib.import_module(configModule + '.configPPinversion') + userConfigInversionVersion = configInversion.configInversionVersion + + # Check sub-config file versions and warn user if they differ + if configInductVersion != userConfigInductVersion: + warn(f'User configPPinduct file is version {userConfigInductVersion}, but the default file is ' + + f'version {configInductVersion}. Some settings may be missing; default values will be used. ' + + f'To align the file version, delete configPPinduct.py and run again, or execute reset.py ' + + f'with python -m PlanetProfile.reset') + if configPlotsVersion != userConfigPlotsVersion: + warn(f'User configPPplots file is version {userConfigPlotsVersion}, but the default file is ' + + f'version {configPlotsVersion}. Some settings may be missing; default values will be used. ' + + f'To align the file version, delete configPPplots.py and run again, or execute reset.py ' + + f'with python -m PlanetProfile.reset') + if configCustomSolutionVersion != userConfigCustomSolutionVersion: + warn(f'User configPPcustomsolution file is version {userConfigCustomSolutionVersion}, but the default file is ' + + f'version {configCustomSolutionVersion}. Some settings may be missing; default values will be used. ' + + f'To align the file version, delete configPPcustomsolution.py and run again, or execute reset.py ' + + f'with python -m PlanetProfile.reset') + if configGravityVersion != userConfigGravityVersion: + warn(f'User configPPgravity file is version {userConfigGravityVersion}, but the default file is ' + + f'version {configGravityVersion}. Some settings may be missing; default values will be used. ' + + f'To align the file version, delete configPPgravity.py and run again, or execute reset.py ' + + f'with python -m PlanetProfile.reset') + if configMonteCarloVersion != userConfigMonteCarloVersion: + warn(f'User configPPmontecarlo file is version {userConfigMonteCarloVersion}, but the default file is ' + + f'version {configMonteCarloVersion}. Some settings may be missing; default values will be used. ' + + f'To align the file version, delete configPPmontecarlo.py and run again, or execute reset.py ' + + f'with python -m PlanetProfile.reset') + if configInversionVersion != userConfigInversionVersion: + warn(f'User configPPinversion file is version {userConfigInversionVersion}, but the default file is ' + + f'version {configInversionVersion}. Some settings may be missing; default values will be used. ' + + f'To align the file version, delete configPPinversion.py and run again, or execute reset.py ' + + f'with python -m PlanetProfile.reset') + userInductAssign = configInduct.inductAssign + userTrajecAssign = configTrajec.trajecAssign + userPlotAssign = configPlots.plotAssign + userCustomSolutionAssign = configCustomSolution.customSolutionAssign + userGravityAssign = configGravity.gravityAssign + userMontecarloAssign = configMonteCarlo.montecarloAssign + userInversionAssign = configInversion.inversionAssign + + userSigParams, userExcSpecParams, userInductParams, userTestBody = userInductAssign() + userTrajecParams = userTrajecAssign() + userColor, userStyle, userFigLbl, userFigSize, userFigMisc = userPlotAssign() + userCustomSolutionParams = userCustomSolutionAssign() + userGravityParams = userGravityAssign() + userMonteCarloParams = userMontecarloAssign() + userInversionParams = userInversionAssign() + + # Load user settings to allow for configuration + for attr, value in userParams.__dict__.items(): + setattr(Params, attr, value) + for attr, value in userExploreParams.__dict__.items(): + setattr(ExploreParams, attr, value) + for attr, value in userSigParams.__dict__.items(): + setattr(SigParams, attr, value) + for attr, value in userExcSpecParams.__dict__.items(): + setattr(ExcSpecParams, attr, value) + for attr, value in userInductParams.__dict__.items(): + setattr(InductParams, attr, value) + for attr, value in userGravityParams.__dict__.items(): + setattr(GravityParams, attr, value) + for attr, value in userMonteCarloParams.__dict__.items(): + setattr(MonteCarloParams, attr, value) + for attr, value in userInversionParams.__dict__.items(): + setattr(InversionParams, attr, value) + + for attr, value in userTrajecParams.__dict__.items(): + setattr(TrajecParams, attr, value) + + for attr, value in userColor.__dict__.items(): + setattr(Color, attr, value) + for attr, value in userStyle.__dict__.items(): + setattr(Style, attr, value) + for attr, value in userFigLbl.__dict__.items(): + setattr(FigLbl, attr, value) + for attr, value in userFigSize.__dict__.items(): + setattr(FigSize, attr, value) + for attr, value in userFigMisc.__dict__.items(): + setattr(FigMisc, attr, value) + for attr, value in userCustomSolutionParams.__dict__.items(): + setattr(CustomSolutionParams, attr, value) + + # Execute necessary adjustments and add non-settings to Params objects + Params.tStart_s = time.time() + + # Get RefProfile file names from the lists set in config file(s) + Params.fNameRef = {comp:f'{comp}Ref.txt' for comp in Params.wRef_ppt.keys()} + # Initialize array dicts for refprofiles + Params.Pref_MPa = {} + Params.rhoRef_kgm3 = {} + Params.nRef = {} + Params.nRefPts = {} + + # Parallel processing toggles + if Params.DO_PARALLEL: + Params.maxCores = mtp.cpu_count() + else: + Params.maxCores = 1 + log.info('DO_PARALLEL is False. Blocking parallel execution.') + + # Add Test body settings to InductParams + inductOgramAttr = ['wMin', 'wMax', 'TbMin', 'TbMax', 'phiMin', 'phiMax', 'rhoMin', + 'rhoMax', 'sigmaMin', 'sigmaMax', 'Dmin', 'Dmax', 'zbFixed_km', 'zbMin', 'zbMax'] + [getattr(InductParams, attr).update({'Test': getattr(InductParams, attr)[userTestBody]}) + for attr in inductOgramAttr] + + # Force calculations to be done for each oscillation to be plotted in inductograms + for osc in InductParams.excSelectionPlot: + if InductParams.excSelectionPlot[osc] and not InductParams.excSelectionCalc[osc]: + InductParams.excSelectionCalc[osc] = True + + # Assign missing values with defaults in inductOgram settings + Tnames = list(InductParams.cLevels['Test'].keys()) + zNames = ['Amp', 'Bx', 'By', 'Bz'] + for bodyname in _DefaultList: + for attr in inductOgramAttr: + if bodyname not in getattr(InductParams, attr).keys(): + getattr(InductParams, attr).update({'Test': getattr(InductParams, attr)[userTestBody]}) + + if bodyname not in InductParams.cLevels.keys(): + InductParams.cLevels[bodyname] = {} + for Tname in Tnames: + InductParams.cLevels[bodyname][Tname] = {zName: None for zName in zNames} + if bodyname not in InductParams.cfmt.keys(): + InductParams.cfmt[bodyname] = {} + for Tname in Tnames: + InductParams.cfmt[bodyname][Tname] = {zName: None for zName in zNames} + + # Assign extrapolation settings for Mixed Clathrates in dictionary + mixedClathExtrap = ['MixedClathrateIh', 'MixedClathrateII', 'MixedClathrateIII', 'MixedClathrateV', 'MixedClathrateVI'] + icePhase = ['Ih', 'II', 'III', 'V', 'VI'] + for i, phase in enumerate(mixedClathExtrap): + if phase not in Params.EXTRAP_ICE.keys(): + Params.EXTRAP_ICE[phase] = Params.EXTRAP_ICE['Clath'] and Params.EXTRAP_ICE[icePhase[i]] + """ + Check CustomSolutionConfig inputs are valid, set file paths, and save global reference to object so Rkt file can use + """ + # Ensure frezchem database is valid + CustomSolutionParams.setPaths(_ROOT) + for file_name in os.listdir(CustomSolutionParams.databasePath): + if file_name == CustomSolutionParams.FREZCHEM_DATABASE: + break + else: + log.warning( + "Input frezchem database does not match any of the available saved files.\nCheck that the input is properly spelled and has .dat at end. Using default frezchem.dat file") + CustomSolutionParams.FREZCHEM_DATABASE = "frezchem.dat" + # Check the unit is 'g' or 'mol' (g - grams, mol - mols) + if not CustomSolutionParams.SPECIES_CONCENTRATION_UNIT == "g" and not CustomSolutionParams.SPECIES_CONCENTRATION_UNIT == "mol": + log.warning( + "Input species concentration unit is not valid. Check that it is either g or mol. Using mol as default") + CustomSolutionParams.SPECIES_CONCENTRATION_UNIT = "mol" + # Load sub-settings into Params so they can be passed around together + Params.Sig = SigParams + Params.Induct = InductParams + Params.MagSpectrum = ExcSpecParams + Params.Explore = ExploreParams + Params.Trajec = TrajecParams + Params.CustomSolution = CustomSolutionParams + Params.Gravity = GravityParams + Params.MonteCarlo = MonteCarloParams + Params.Inversion = InversionParams + +loadUserSettings() \ No newline at end of file diff --git a/PlanetProfile/Gravity/Gravity.py b/PlanetProfile/Gravity/Gravity.py index 73a97b3b..590c9e72 100644 --- a/PlanetProfile/Gravity/Gravity.py +++ b/PlanetProfile/Gravity/Gravity.py @@ -4,14 +4,16 @@ import ast import os from PlanetProfile.Utilities.Indexing import PhaseConv -from PlanetProfile.Utilities.defineStructs import Constants +from PlanetProfile.Utilities.defineStructs import Constants, Timing +import time # Assign logger log = logging.getLogger('PlanetProfile') def GravityParameters(Planet, Params): """ Calculate induced gravity responses for the body and prints them to disk.""" - if Planet.Do.VALID and Params.CALC_NEW_GRAVITY and Params.CALC_VISCOSITY and Params.CALC_SEISMIC and not Params.SKIP_INNER: + Timing.setFunctionTime(time.time()) + if (Planet.Do.VALID or (Params.ALLOW_BROKEN_MODELS and Planet.Do.STILL_CALCULATE_BROKEN_PROPERTIES)) and Params.CALC_NEW_GRAVITY and Params.CALC_VISCOSITY and Params.CALC_SEISMIC and not Params.SKIP_INNER: # Check if there are any phase transitions in the model, or if this is a 1-layer model if not np.any(Planet.Reduced.phase[:-1] != Planet.Reduced.phase[1:]): # If we have only one phase in our model, pyalma is not able to calculate love numbers so we must pass @@ -23,20 +25,30 @@ def GravityParameters(Planet, Params): Planet.Gravity.ALMAModel['model'][:, Planet.Gravity.ALMAModel['columns'].index('rho')], Planet.Gravity.ALMAModel['mu'], Planet.Gravity.ALMAModel['vis'], - Planet.Gravity.rheology, Planet.Gravity.pyAlmaParams, ndigits=Params.Gravity.num_digits, verbose=Params.Gravity.verbose, parallel=False) # False parallel for now since it is throwing a bad file desciptor + Planet.Gravity.rheology, Planet.Gravity.pyAlmaParams, ndigits=Params.Gravity.num_digits, verbose=(not Params.QUIET_ALMA and Params.Gravity.verbose), parallel=False) # False parallel for now since it is throwing a bad file desciptor # Compute love numbers Planet.Gravity.h, Planet.Gravity.l, Planet.Gravity.k = love_numbers(Params.Gravity.harmonic_degrees, Params.Gravity.time_log_kyrs, Params.Gravity.loading_type, Params.Gravity.time_history_function, Params.Gravity.tau, model_params, - Params.Gravity.output_type, Params.Gravity.gorder, verbose=Params.Gravity.verbose, + Params.Gravity.output_type, Params.Gravity.gorder, verbose=(not Params.QUIET_ALMA and Params.Gravity.verbose), parallel=Params.Gravity.parallel and not (Params.INDUCTOGRAM_IN_PROGRESS or Params.DO_EXPLOREOGRAM)) # Compute delta relation Planet.Gravity.delta = 1 + Planet.Gravity.k - Planet.Gravity.h + # If our love numbers are 1x1 numpy array, let's convert to float - important for plotting and output if len(Planet.Gravity.time_log_kyrs) == 1 and len(Planet.Gravity.harmonic_degrees) == 1: - Planet.Gravity.h = float(Planet.Gravity.h[0, 0]) - Planet.Gravity.l = float(Planet.Gravity.l[0, 0]) - Planet.Gravity.k = float(Planet.Gravity.k[0, 0]) - Planet.Gravity.delta = float(Planet.Gravity.delta[0, 0]) + Planet.Gravity.h = Planet.Gravity.h[0, 0] + Planet.Gravity.l = Planet.Gravity.l[0, 0] + Planet.Gravity.k = Planet.Gravity.k[0, 0] + Planet.Gravity.delta = Planet.Gravity.delta[0, 0] + # Convert love numbers from complex to magnitude and phase delay + Planet.Gravity.hAmp = np.abs(Planet.Gravity.h) + Planet.Gravity.hPhase = -np.degrees(np.angle(Planet.Gravity.h)) + Planet.Gravity.lAmp = np.abs(Planet.Gravity.l) + Planet.Gravity.lPhase = -np.degrees(np.angle(Planet.Gravity.l)) + Planet.Gravity.kAmp = np.abs(Planet.Gravity.k) + Planet.Gravity.kPhase = -np.degrees(np.angle(Planet.Gravity.k)) + Planet.Gravity.deltaAmp = np.abs(Planet.Gravity.delta) + Planet.Gravity.deltaPhase = -np.degrees(np.angle(Planet.Gravity.delta)) if (not Params.NO_SAVEFILE) and (not Params.INVERSION_IN_PROGRESS) and (not Params.DO_EXPLOREOGRAM): Planet, Params = WriteGravityParameters(Planet, Params) elif Planet.Do.VALID: @@ -46,6 +58,7 @@ def GravityParameters(Planet, Params): else: log.warning( f'CALC_NEW_GRAVITY is False, but {Params.DataFiles.gravityParametersFile} was not found. ' + f'Skipping gravity parameter calculations.') + Timing.printFunctionTimeDifference('GravityParameters()', time.time()) return Planet, Params @@ -59,131 +72,130 @@ def SetupGravity(Planet, Params): Requires Planet attributes: """ - if Params.CALC_NEW_GRAVITY and Planet.Do.VALID and not Params.SKIP_INNER: - """Combine data into model format that is required by PyALMA3""" - # Note we have to use r_m[:-1] since r_m has one extra value than other arrays - Planet.Gravity.model = np.vstack( - [Planet.Reduced.r_m, Planet.Reduced.phase, Planet.Reduced.rho_kgm3, Planet.Reduced.Seismic.VP_kms, Planet.Reduced.Seismic.VS_kms, Planet.Reduced.Seismic.GS_GPa, Planet.Reduced.eta_Pas]).T + """Combine data into model format that is required by PyALMA3""" + # Note we have to use r_m[:-1] since r_m has one extra value than other arrays + Planet.Gravity.model = np.vstack( + [Planet.Reduced.r_m, Planet.Reduced.phase, Planet.Reduced.rho_kgm3, Planet.Reduced.Seismic.VP_kms, Planet.Reduced.Seismic.VS_kms, Planet.Reduced.Seismic.GS_GPa, Planet.Reduced.eta_Pas]).T + + # Convert parameter units to Pa and meters + for index, (header, unit) in enumerate(zip(Planet.Gravity.columns, Planet.Gravity.units_PyALMA3)): + if header in Planet.Gravity.parameters_to_convert: + Planet.Gravity.model[:, index] = Planet.Gravity.model[:, index] * Planet.Gravity.parameters_to_convert[header] + # Get indices of used properties + rIndex = Planet.Gravity.columns.index('r') + pIndex = Planet.Gravity.columns.index('phase') + rhoIndex = Planet.Gravity.columns.index('rho') + VPIndex = Planet.Gravity.columns.index('VP') + VSIndex = Planet.Gravity.columns.index('VS') + GSIndex = Planet.Gravity.columns.index('GS') + etaIndex = Planet.Gravity.columns.index('eta') + # Round radius - In similar PyALMA3 function, it rounds radius so will do so here as well + Planet.Gravity.model[:, rIndex] = np.round(Planet.Gravity.model[:, rIndex], 0) + + # Flip model: core at top and surface at bottom + Planet.Gravity.model = np.flipud(Planet.Gravity.model) + + ## Calculate elastic parameters + # LAMBDA = rho (Vp^2 - 2 * Vs^2) + # 1st Lame parameter + Planet.Gravity.LAMBDA_Pa = Planet.Gravity.model[:, rhoIndex] * ( + np.power(Planet.Gravity.model[:, VPIndex], 2) - + 2. * np.power(Planet.Gravity.model[:, VSIndex], 2)) + + # shear modulus G or MU = rho Vs^2 + Planet.Gravity.MU_Pa = Planet.Gravity.model[:, GSIndex] + + # Poissons ratio sigma = lambda / 2*(lambda + mu) + Planet.Gravity.SIGMA = Planet.Gravity.LAMBDA_Pa / (2 * Planet.Gravity.LAMBDA_Pa + 2 * Planet.Gravity.MU_Pa) + + # Youngs modulus Y = 2 * MU * (1 + sigma) + Planet.Gravity.Y_Pa = 2. * Planet.Gravity.MU_Pa * (1 + Planet.Gravity.SIGMA) + + Planet.Gravity.VISCOSITY_kgms = Planet.Gravity.model[:, etaIndex] + + Planet.Gravity.ALMAModel = {'columns': Planet.Gravity.columns, + 'units': Planet.Gravity.units_PyALMA3, + 'model': Planet.Gravity.model, + 'lambda': Planet.Gravity.LAMBDA_Pa, + 'mu': Planet.Gravity.MU_Pa, + 'sigma': Planet.Gravity.SIGMA, + 'y': Planet.Gravity.Y_Pa, + 'vis': Planet.Gravity.VISCOSITY_kgms} + + # Set Planet time scale and harmonic degrees from Params + Planet.Gravity.time_log_kyrs = Params.Gravity.time_log_kyrs + Planet.Gravity.harmonic_degrees = Params.Gravity.harmonic_degrees + + # Finally, we must setup the rheology structure, from core to surface + layers = [] # List of layer indices where layer change occurs (index right before the change) + # Find where phase changes occur + phases = Planet.Gravity.model[:, pIndex] + # Flip changeIndices so that the largest index becomes 0 and 0 becomes the largest + # This reverses the ordering while maintaining the relative spacing between indices + changeIndices = np.max(Planet.Reduced.changeIndices) - np.flipud(Planet.Reduced.changeIndices) + rheology_structure = [] + for start, end in zip(changeIndices[:-1], changeIndices[1:]): + if end != changeIndices[-1]: # Exclude the last index, which is the end of the model + layers.append(end - 1) + phase = phases[start] + if phase >= Constants.phaseClath and phase < Constants.phaseClath + 10: + phase = Constants.phaseClath # Reset phase to phase Clathrate so we can use that rheology model in the config - namely, we treat rheology of mixed layers as same as we treat clathrate + convection = np.flipud(Planet.Reduced.iConv)[start] + # Convert numerical phase to string representation for dictionary lookup + phase_str = PhaseConv(phase, liq='0') + if convection: + phase_str += '_conv' + if phase_str not in Params.Gravity.rheology_models: + raise ValueError(f"Phase {phase_str} not found in rheology models.") + else: + rheology_model = Params.Gravity.rheology_models[phase_str] + rheology_structure.append(rheology_model) + + # Store the compiled structures in Planet.Gravity + Params.Gravity.rheology_structure = rheology_structure + + # Verify we have the right number of structural regions + if len(rheology_structure) != len(layers) + 1: + raise ValueError(f"Number of rheology structures ({len(rheology_structure)}) does not match " + f"number of layer regions ({len(layers) + 1})") + + # Create rheology array for each model layer + rheo = [] + for layer_idx in range(len(rheology_structure)): + if layer_idx == 0: + # First region: from start to first transition + end_idx = layers[layer_idx] + 1 if layers else len(phases) + rheo.extend([rheology_structure[layer_idx] for _ in range(end_idx)]) + + elif layer_idx < len(rheology_structure) - 1: + # Intermediate regions: from previous transition to next transition + start_idx = layers[layer_idx - 1] + 1 + end_idx = layers[layer_idx] + 1 + rheo.extend([rheology_structure[layer_idx] for _ in range(end_idx - start_idx)]) + + else: + # Last region: from last transition to end + start_idx = layers[-1] + 1 + rheo.extend([rheology_structure[layer_idx] for _ in range(len(phases) - start_idx)]) + + # Create parameters array (can be customized for Andrade/Burgers layers) + params = np.zeros((len(rheo), 2)) - # Convert parameter units to Pa and meters - for index, (header, unit) in enumerate(zip(Planet.Gravity.columns, Planet.Gravity.units_PyALMA3)): - if header in Planet.Gravity.parameters_to_convert: - Planet.Gravity.model[:, index] = Planet.Gravity.model[:, index] * Planet.Gravity.parameters_to_convert[header] - # Get indices of used properties - rIndex = Planet.Gravity.columns.index('r') - pIndex = Planet.Gravity.columns.index('phase') - rhoIndex = Planet.Gravity.columns.index('rho') - VPIndex = Planet.Gravity.columns.index('VP') - VSIndex = Planet.Gravity.columns.index('VS') - GSIndex = Planet.Gravity.columns.index('GS') - etaIndex = Planet.Gravity.columns.index('eta') - # Round radius - In similar PyALMA3 function, it rounds radius so will do so here as well - Planet.Gravity.model[:, rIndex] = np.round(Planet.Gravity.model[:, rIndex], 0) - - # Flip model: core at top and surface at bottom - Planet.Gravity.model = np.flipud(Planet.Gravity.model) - - ## Calculate elastic parameters - # LAMBDA = rho (Vp^2 - 2 * Vs^2) - # 1st Lame parameter - Planet.Gravity.LAMBDA_Pa = Planet.Gravity.model[:, rhoIndex] * ( - np.power(Planet.Gravity.model[:, VPIndex], 2) - - 2. * np.power(Planet.Gravity.model[:, VSIndex], 2)) - - # shear modulus G or MU = rho Vs^2 - Planet.Gravity.MU_Pa = Planet.Gravity.model[:, GSIndex] - - # Poissons ratio sigma = lambda / 2*(lambda + mu) - Planet.Gravity.SIGMA = Planet.Gravity.LAMBDA_Pa / (2 * Planet.Gravity.LAMBDA_Pa + 2 * Planet.Gravity.MU_Pa) - - # Youngs modulus Y = 2 * MU * (1 + sigma) - Planet.Gravity.Y_Pa = 2. * Planet.Gravity.MU_Pa * (1 + Planet.Gravity.SIGMA) - - Planet.Gravity.VISCOSITY_kgms = Planet.Gravity.model[:, etaIndex] - - Planet.Gravity.ALMAModel = {'columns': Planet.Gravity.columns, - 'units': Planet.Gravity.units_PyALMA3, - 'model': Planet.Gravity.model, - 'lambda': Planet.Gravity.LAMBDA_Pa, - 'mu': Planet.Gravity.MU_Pa, - 'sigma': Planet.Gravity.SIGMA, - 'y': Planet.Gravity.Y_Pa, - 'vis': Planet.Gravity.VISCOSITY_kgms} - - # Set Planet time scale and harmonic degrees from Params - Planet.Gravity.time_log_kyrs = Params.Gravity.time_log_kyrs - Planet.Gravity.harmonic_degrees = Params.Gravity.harmonic_degrees + # Finally, let's create our own PyALMA3 parameters structure for Andrade and Burgers layers + # We do this because the infer_rheology_pp function doesn't let you customize the Andrade exponent + for i, rheol in enumerate(rheo): + if rheol == 'andrade' or rheol == 6: + # Set Andrade exponent (alpha) - default to 0.2, can be customized + params[i, 0] = Planet.Gravity.andradExponent + # params[i, 1] will be auto-calculated as gamma(alpha + 1) + elif rheol == 'burgers' or rheol == 5: + # Set Burgers parameters - can be customized + params[i, 0] = Planet.Gravity.BurgerFirstParameter # mu2/mu1 ratio + params[i, 1] = Planet.Gravity.BurgerSecondParameter # eta2/eta1 ratio - # Finally, we must setup the rheology structure, from core to surface - layers = [] # List of layer indices where layer change occurs (index right before the change) - # Find where phase changes occur - phases = Planet.Gravity.model[:, pIndex] - # Flip changeIndices so that the largest index becomes 0 and 0 becomes the largest - # This reverses the ordering while maintaining the relative spacing between indices - changeIndices = np.max(Planet.Reduced.changeIndices) - np.flipud(Planet.Reduced.changeIndices) - rheology_structure = [] - for start, end in zip(changeIndices[:-1], changeIndices[1:]): - if end != changeIndices[-1]: # Exclude the last index, which is the end of the model - layers.append(end - 1) - phase = phases[start] - if phase >= Constants.phaseClath and phase < Constants.phaseClath + 10: - phase = Constants.phaseClath # Reset phase to phase Clathrate so we can use that rheology model in the config - namely, we treat rheology of mixed layers as same as we treat clathrate - convection = np.flipud(Planet.Reduced.iConv)[start] - # Convert numerical phase to string representation for dictionary lookup - phase_str = PhaseConv(phase, liq='0') - if convection: - phase_str += '_conv' - if phase_str not in Params.Gravity.rheology_models: - raise ValueError(f"Phase {phase_str} not found in rheology models.") - else: - rheology_model = Params.Gravity.rheology_models[phase_str] - rheology_structure.append(rheology_model) - - # Store the compiled structures in Planet.Gravity - Params.Gravity.rheology_structure = rheology_structure - - # Verify we have the right number of structural regions - if len(rheology_structure) != len(layers) + 1: - raise ValueError(f"Number of rheology structures ({len(rheology_structure)}) does not match " - f"number of layer regions ({len(layers) + 1})") - - # Create rheology array for each model layer - rheo = [] - for layer_idx in range(len(rheology_structure)): - if layer_idx == 0: - # First region: from start to first transition - end_idx = layers[layer_idx] + 1 if layers else len(phases) - rheo.extend([rheology_structure[layer_idx] for _ in range(end_idx)]) - - elif layer_idx < len(rheology_structure) - 1: - # Intermediate regions: from previous transition to next transition - start_idx = layers[layer_idx - 1] + 1 - end_idx = layers[layer_idx] + 1 - rheo.extend([rheology_structure[layer_idx] for _ in range(end_idx - start_idx)]) - - else: - # Last region: from last transition to end - start_idx = layers[-1] + 1 - rheo.extend([rheology_structure[layer_idx] for _ in range(len(phases) - start_idx)]) - - # Create parameters array (can be customized for Andrade/Burgers layers) - params = np.zeros((len(rheo), 2)) - - # Finally, let's create our own PyALMA3 parameters structure for Andrade and Burgers layers - # We do this because the infer_rheology_pp function doesn't let you customize the Andrade exponent - for i, rheol in enumerate(rheo): - if rheol == 'andrade' or rheol == 6: - # Set Andrade exponent (alpha) - default to 0.2, can be customized - params[i, 0] = Planet.Gravity.andradExponent - # params[i, 1] will be auto-calculated as gamma(alpha + 1) - elif rheol == 'burgers' or rheol == 5: - # Set Burgers parameters - can be customized - params[i, 0] = Planet.Gravity.BurgerFirstParameter # mu2/mu1 ratio - params[i, 1] = Planet.Gravity.BurgerSecondParameter # eta2/eta1 ratio - - # Store rheology and params for use in build_model - Planet.Gravity.rheology = rheo - Planet.Gravity.pyAlmaParams = params + # Store rheology and params for use in build_model + Planet.Gravity.rheology = rheo + Planet.Gravity.pyAlmaParams = params # Return Planet and Params return Planet, Params @@ -214,12 +226,21 @@ def ReloadGravityParameters(Planet, Params): Planet.Gravity.harmonic_degrees = ast.literal_eval(f.readline().split('=')[-1]) if len(Planet.Gravity.time_log_kyrs) == 1 and len(Planet.Gravity.harmonic_degrees) == 1: # Make these variables floats - Planet.Gravity.h = float((f.readline().split('=')[-1])) - Planet.Gravity.l = float((f.readline().split('=')[-1])) - Planet.Gravity.k = float((f.readline().split('=')[-1])) + Planet.Gravity.h = np.complex_((f.readline().split('=')[-1])) + Planet.Gravity.l = np.complex_((f.readline().split('=')[-1])) + Planet.Gravity.k = np.complex_((f.readline().split('=')[-1])) else: - Planet.Gravity.h = np.array(ast.literal_eval(f.readline().split('=')[-1])) - Planet.Gravity.l = np.array(ast.literal_eval(f.readline().split('=')[-1])) - Planet.Gravity.k = np.array(ast.literal_eval(f.readline().split('=')[-1])) + Planet.Gravity.h = np.array(ast.literal_eval(f.readline().split('=')[-1]), dtype=np.complex_) + Planet.Gravity.l = np.array(ast.literal_eval(f.readline().split('=')[-1]), dtype=np.complex_) + Planet.Gravity.k = np.array(ast.literal_eval(f.readline().split('=')[-1]), dtype=np.complex_) Planet.Gravity.delta = 1 + Planet.Gravity.k - Planet.Gravity.h + # Convert love numbers from complex to magnitude and phase delay + Planet.Gravity.hAmp = np.abs(Planet.Gravity.h) + Planet.Gravity.hPhase = -np.degrees(np.angle(Planet.Gravity.h)) + Planet.Gravity.lAmp = np.abs(Planet.Gravity.l) + Planet.Gravity.lPhase = -np.degrees(np.angle(Planet.Gravity.l)) + Planet.Gravity.kAmp = np.abs(Planet.Gravity.k) + Planet.Gravity.kPhase = -np.degrees(np.angle(Planet.Gravity.k)) + Planet.Gravity.deltaAmp = np.abs(Planet.Gravity.delta) + Planet.Gravity.deltaPhase = -np.degrees(np.angle(Planet.Gravity.delta)) return Planet, Params diff --git a/PlanetProfile/Gravity/defaultConfigGravity.py b/PlanetProfile/Gravity/defaultConfigGravity.py index 863387d8..344dee69 100644 --- a/PlanetProfile/Gravity/defaultConfigGravity.py +++ b/PlanetProfile/Gravity/defaultConfigGravity.py @@ -1,7 +1,7 @@ """ Configuration settings specific to gravity response calculations and plots """ from PlanetProfile.Utilities.defineStructs import GravityParamsStruct -configGravityVersion = 3 # Integer number for config file version. Increment when new settings are added to the default config file. +configGravityVersion = 4 # Integer number for config file version. Increment when new settings are added to the default config file. def gravityAssign(): GravityParams = GravityParamsStruct() @@ -12,14 +12,14 @@ def gravityAssign(): GravityParams.parallel = False # Use Parallel computing for PyALMA calculations. #TODO: Need to implement way to do this if Parallel already being used in Exploreogram # Parsing parameters - GravityParams.rheology_models = {'0': 'newton', 'Ih': 'maxwell', 'Ih_conv': 'andrade','II': 'maxwell', 'III': 'maxwell', 'III_conv': 'andrade', + GravityParams.rheology_models = {'0': 'newton', 'Ih': 'elastic', 'Ih_conv': 'andrade','II': 'maxwell', 'III': 'maxwell', 'III_conv': 'andrade', 'IV': 'maxwell', 'V': 'maxwell','V_conv': 'andrade', 'VI': 'maxwell', 'Sil': 'elastic', 'Fe': 'elastic', 'Clath': 'newton', 'Clath_conv': 'andrade'} # Rheology structure model, where each model corresponds to a layer # General parameters GravityParams.num_digits = 128 # Set precision - GravityParams.gorder = 8 # Order of Gaver method - GravityParams.tau = 0 # TODO: FIGURE OUT WHAT TAU DOES + GravityParams.gorder = 4 # Order of Gaver method + GravityParams.tau = 2 # TODO: FIGURE OUT WHAT TAU DOES GravityParams.loading_type = 'tidal' # Loading type to calculate love numbers - 'tidal' or 'loading' # Harmonic degrees parameters @@ -29,6 +29,6 @@ def gravityAssign(): GravityParams.ramp_function_length_kyrs = None # Ramp length in kyrs # Output parameters - GravityParams.output_type = 'real' # Output type - 'complex' or 'real' + GravityParams.output_type = 'complex' # Output type - 'complex' or 'real' return GravityParams diff --git a/PlanetProfile/Inversion/Inversion.py b/PlanetProfile/Inversion/Inversion.py new file mode 100644 index 00000000..a6eabc60 --- /dev/null +++ b/PlanetProfile/Inversion/Inversion.py @@ -0,0 +1,782 @@ +from PlanetProfile.Utilities.defineStructs import InversionParamsStruct +from PlanetProfile.Main import WriteProfile, ReloadProfile, ExploreOgram +import numpy as np +import matplotlib.pyplot as plt +import matplotlib.patches as patches +import numpy as np +from matplotlib.gridspec import GridSpec +from PlanetProfile.Plotting.EssentialHelpers import * +import logging +logger = logging.getLogger('PlanetProfile') +def InvertBestPlanetList(BestPlanetList, Params, fNames): + ExplorationList = [] + if len(fNames) == 1: + Exploration, Params = ExploreOgram(BestPlanetList[0].bodyname, Params, fNameOverride=fNames[0]) + ExplorationList.append(Exploration) + else: + for fName in fNames: + Exploration, Params = ExploreOgram(BestPlanetList[0].bodyname, Params, fNameOverride=fName) + ExplorationList.append(Exploration) + InvertBestPlanetMultiplot(BestPlanetList, ExplorationList, Params) + +def InvertBestPlanet(BestPlanet, Params, fNames): + """ + Invert for best-fit interior structure/constraints based on a set of input parameters. + + Now supports both single planet and list of planets: + - If BestPlanet is a single planet object, creates single uncertainty plot + - If BestPlanet is a list of planets, creates multiplot with all planets + """ + Params.Inversion.assignRealPlanetModel(BestPlanet, BestPlanet.xBestFit, BestPlanet.yBestFit) + ExplorationList = [] + if len(fNames) == 1: + Exploration, Params = ExploreOgram(BestPlanet.bodyname, Params, fNameOverride=fNames[0]) + ExplorationList.append(Exploration) + else: + for fName in fNames: + Exploration, Params = ExploreOgram(BestPlanet.bodyname, Params, fNameOverride=fName) + ExplorationList.append(Exploration) + ExplorationList = FitWithinUncertainty(ExplorationList, Params) + + PlotUncertainty(ExplorationList, Params) + + +def InvertBestPlanetMultiplot(BestPlanetList, Exploration, Params): + """ + Create uncertainty plots for multiple best-fit planets in a single multiplot figure. + + Similar to PlotExploreOgramMultiSubplot, this function: + 1. Takes a list of best-fit planets instead of a single planet + 2. Creates exploration results for each planet + 3. Arranges them in a square-ish grid layout + 4. Uses shared legends and axes (only left column and bottom row labels) + 5. Saves only the combined multiplot + + Args: + BestPlanetList: List of best-fit planet objects + Params: Parameters object with inversion settings + fName: Base filename for exploration results + """ + + n_planets = len(BestPlanetList) + if n_planets == 0: + log.warning("No planets provided for uncertainty multiplot") + return + + # Calculate square-ish grid layout + n_cols = 4 + n_rows = 2 + + # Calculate figure size with scaling (same as PlotExploreOgramMultiSubplot) + base_size = (6, 4) # Same as PlotUncertainty default + scale_factor = 1 + fig_width = base_size[0] * n_cols * scale_factor + fig_height = base_size[1] * n_rows * scale_factor + + # Create figure with subplots + fig = plt.figure(figsize=(fig_width, fig_height)) + + # Create subplots and plot uncertainty for each planet + axes = [] + legend_elements = None # Store legend elements from first plot + + for i, Planet in enumerate(BestPlanetList): + # Fill by column: first column (0, 2, 4, 6), then second column (1, 3, 5, 7) + col = i // n_rows + row = i % n_rows + subplot_index = row * n_cols + col + 1 + ax = fig.add_subplot(n_rows, n_cols, subplot_index) + axes.append(ax) + + Params.Inversion.assignRealPlanetModel(Planet, Planet.xBestFit, Planet.yBestFit) + + # Call PlotUncertainty with this specific axis + ax_result = PlotUncertaintySubplot(Exploration, Params, ax=ax, planet_name=Planet.bodyname) + + # Remove legend from all plots except the first one + if i > 0 and ax.get_legend() is not None: + ax.get_legend().remove() + + + # Add subplot label (a, b, c, etc.) if enabled + if hasattr(FigMisc, 'SUBPLOT_LABELS') and FigMisc.SUBPLOT_LABELS: + label = f"{chr(ord('a') + i)}" + if hasattr(FigMisc, 'SUBPLOT_LABEL_X'): + label_x = FigMisc.SUBPLOT_LABEL_X + label_y = FigMisc.SUBPLOT_LABEL_Y + label_fontsize = FigMisc.SUBPLOT_LABEL_FONTSIZE + else: + label_x, label_y, label_fontsize = 0.02, 0.98, 12 + + ax.text(label_x, label_y, label, + transform=ax.transAxes, fontsize=label_fontsize, + fontweight='bold', ha='left', va='top', + bbox=dict(boxstyle='round,pad=0.3', facecolor='white', alpha=0.8)) + + # Hide axis labels selectively (only show left column and bottom row) + is_bottom_row = (row == n_rows - 1) or (i >= n_planets - n_rows) + is_left_column = (col == 0) + + if not is_bottom_row: + ax.set_xlabel('') + ax.tick_params(axis='x', labelbottom=False) + + if not is_left_column: + ax.set_ylabel('') + ax.tick_params(axis='y', labelleft=False) + # Hide unused subplots + total_subplots = n_rows * n_cols + for i in range(n_planets, total_subplots): + # Calculate subplot position for unused plots + col = i // n_rows + row = i % n_rows + subplot_index = row * n_cols + col + 1 + ax = fig.add_subplot(n_rows, n_cols, subplot_index) + ax.set_visible(False) + # Set overall title + if hasattr(Params, 'TITLES') and Params.TITLES: + fig.suptitle('Uncertainty Analysis - Multiple Best-Fit Planets', fontsize=15) + + # Save the combined figure + plt.tight_layout() + + # Save to uncertainty multiplot file + if hasattr(Params, 'FigureFiles') and hasattr(Params.FigureFiles, 'path'): + fig_path = f"{Params.FigureFiles.path}/uncertainty_multiplot.pdf" + fig.savefig(fig_path, format='pdf', dpi=300, bbox_inches='tight') + log.debug(f'Uncertainty multiplot saved to file: {fig_path}') + + plt.close() + + +def PlotUncertaintySubplot(Exploration, Params, ax=None, planet_name=None): + """ + Subplot version of PlotUncertainty - simply calls PlotUncertainty with ax parameter. + + Args: + Exploration: Exploration results object + Params: Parameters object with inversion settings + ax: Matplotlib axis to plot on (required for subplot) + planet_name: Optional planet name for custom subplot title + """ + return PlotUncertainty(Exploration, Params, ax=ax, planet_name=planet_name) +def FitWithinUncertainty(ExplorationList, Params): + """ + Fit the best-fit model within the uncertainty of the best-fit model. + """ + for Exploration in ExplorationList: + xInductionResponseUncertainty = CalcGridWithinUncertainty(Params.Inversion.Bi1xyz_nT['x'], Exploration.induction.Bi1x_nT, Params.Inversion.InductionResponseUncertainty_nT) + yInductionResponseUncertainty = CalcGridWithinUncertainty(Params.Inversion.Bi1xyz_nT['y'], Exploration.induction.Bi1y_nT, Params.Inversion.InductionResponseUncertainty_nT) + zInductionResponseUncertainty = CalcGridWithinUncertainty(Params.Inversion.Bi1xyz_nT['z'], Exploration.induction.Bi1z_nT, Params.Inversion.InductionResponseUncertainty_nT) + Exploration.inversion.gridWithinInductionResponseUncertainty = np.all([xInductionResponseUncertainty, yInductionResponseUncertainty, zInductionResponseUncertainty], axis=0) + Exploration.inversion.gridWithinkLoveAmpUncertainty = CalcGridWithinUncertainty(Params.Inversion.kLoveAmp, Exploration.base.kLoveAmp, Params.Inversion.kLoveAmpUncertainity) + Exploration.inversion.gridWithinhLoveAmpUncertainty = CalcGridWithinUncertainty(Params.Inversion.hLoveAmp, Exploration.base.hLoveAmp, Params.Inversion.hLoveAmpUncertainity) + Exploration.inversion.gridWithinAllUncertainty = np.all([Exploration.inversion.gridWithinInductionResponseUncertainty, Exploration.inversion.gridWithinkLoveAmpUncertainty, Exploration.inversion.gridWithinhLoveAmpUncertainty], axis=0) + return ExplorationList + +def CalcGridWithinUncertainty(bestPlanetData, GridData, UncertaintyData): + """ + Calculate the grid of models within the uncertainty of the best-fit model. + For complex data, uses magnitude of difference (circular uncertainty region). + For real data, uses direct comparison (linear uncertainty bounds). + + Args: + bestPlanetData: The best-fit model data value (real or complex) + GridData: 2D array of grid data to compare against (real or complex) + UncertaintyData: Uncertainty range for the best-fit model + + Returns: + Boolean 2D array indicating which grid points are within uncertainty + """ + # Check if data is complex + if np.iscomplexobj(bestPlanetData) or np.iscomplexobj(GridData): + # For complex data, check if magnitude of difference is within uncertainty + # This creates a circular uncertainty region in the complex plane + # GridData shape: (nexc, planetGridWidth, planetGridHeight) + # bestPlanetData shape: (nexc,) + # Need to broadcast bestPlanetData to match GridData dimensions + bestPlanetData_expanded = bestPlanetData[:, np.newaxis, np.newaxis] + realGridData = np.real(GridData) + imagGridData = np.imag(GridData) + realBestPlanetData = np.real(bestPlanetData_expanded) + imagBestPlanetData = np.imag(bestPlanetData_expanded) + realDifference = np.abs(realGridData - realBestPlanetData) + imagDifference = np.abs(imagGridData - imagBestPlanetData) + realWithinUncertainty = realDifference <= UncertaintyData + imagWithinUncertainty = imagDifference <= UncertaintyData + within_uncertainty = np.logical_and(realWithinUncertainty, imagWithinUncertainty) + + # If within_uncertainty is 3D (nexc, planetGridWidth, planetGridHeight), + # check if any of the nexc rows are within uncertainty + if within_uncertainty.ndim == 3: + if within_uncertainty.shape[0] == 0: + within_uncertainty = np.zeros((within_uncertainty.shape[1], within_uncertainty.shape[2]), dtype=bool) + else: + within_uncertainty = np.all(within_uncertainty, axis=0) + else: + # For real data, use direct comparison (linear bounds) + within_uncertainty = _check_within_bounds(bestPlanetData, GridData, UncertaintyData) + return within_uncertainty + +def _compute_interpolated_uncertainty_regions(Exploration, Params, x_data, y_data, x_interp, y_interp): + """ + Compute interpolation-aware uncertainty regions for combined contours. + + Args: + Exploration: Exploration results object + Params: Parameters object with inversion settings + x_data, y_data: Original coordinate grids + x_interp, y_interp: Interpolated coordinate grids + + Returns: + Dictionary with boolean arrays for different parameter group combinations + """ + regions = {} + + # Compute individual parameter uncertainty regions on interpolated grid + k_love_region = None + h_love_region = None + induction_regions = [] + + # k Love number region + k_love_interp = _interpolate_z_data(x_data, y_data, Exploration.base.kLoveAmp, x_interp, y_interp) + k_love_region = _check_within_bounds( + Params.Inversion.kLoveAmp, k_love_interp, Params.Inversion.kLoveAmpUncertainity + ) + + # h Love number region + + h_love_interp = _interpolate_z_data(x_data, y_data, Exploration.base.hLoveAmp, x_interp, y_interp) + h_love_region = _check_within_bounds( + Params.Inversion.hLoveAmp, h_love_interp, Params.Inversion.hLoveAmpUncertainity + ) + + # Induction component regions + nExc, nExcNames = count_plottable_excitations(Exploration.induction.calcedExc, Params.Induct) + inductionData = np.zeros((nExc, x_data.shape[0], x_data.shape[1]), dtype=np.complex_) + bestInductionData = np.zeros((nExc), dtype=np.complex_) + for iExc, nExcName in enumerate(nExcNames): + Exploration.excName = nExcName + data = extract_magnetic_field_data(Exploration, 'Bi1x_nT') + inductionData[iExc, :, :] = data + bestInductionData[iExc] = Params.Inversion.Bi1xyz_nT['x'][iExc] + data_interp = _interpolate_z_data(x_data, y_data, inductionData, x_interp, y_interp) + regions['induction'] = CalcGridWithinUncertainty( + bestInductionData, data_interp, Params.Inversion.InductionResponseUncertainty_nT + ) + + # Combine regions using intersection (logical AND) + regions['gravity'] = k_love_region & h_love_region + + regions['all_data'] = regions['gravity'] & regions['induction'] + + return regions + + +def _plot_boolean_contour(ax, x_data, y_data, boolean_region, color, alpha): + """ + Plot a contour from a boolean region mask. + + Args: + ax: Matplotlib axis + x_data, y_data: Coordinate arrays + boolean_region: Boolean array indicating region to contour + color: Contour color + alpha: Transparency + + Returns: + Matplotlib contour object or None + """ + if boolean_region is None: + return None + + # Convert boolean mask to numeric for contouring + z_mask = np.where(boolean_region, 1.0, 0.0) + + # Plot filled contour + contour = ax.contourf(x_data, y_data, z_mask, + levels=[0.5, 1.5], # Contour at value 1.0 + colors=[color], alpha=alpha, zorder=2) + + # Plot contour outline + contour_outline = ax.contour(x_data, y_data, z_mask, + levels=[0.5], # Contour at value 0.5 (edge of region) + colors=[color], alpha=0.8, linewidths=2) + + return contour + + +def _check_within_bounds(best_value, grid_values, uncertainty): + """ + Helper function to check if grid values are within uncertainty bounds. + + Args: + best_value: Reference value (scalar) + grid_values: Array of values to check + uncertainty: Uncertainty range + + Returns: + Boolean array indicating which values are within bounds + """ + upper_bound = best_value + uncertainty + lower_bound = best_value - uncertainty + return (grid_values >= lower_bound) & (grid_values <= upper_bound) + + +def PlotUncertainty(ExplorationList, Params, ax=None, planet_name=None): + """ + Plot the uncertainty of the best-fit model. + + Creates a plot showing uncertainty regions using overlapping filled contours + of physical quantities at best-fit ± uncertainty bounds. Shows separate + contours for x, y, z induction components plus Love number uncertainties. + Supports combined contours for parameter groups (gravity, induction, all data). + + Args: + Exploration: Exploration results object + Params: Parameters object with inversion settings + ax: Optional matplotlib axis for subplot usage + planet_name: Optional planet name for custom subplot title + """ + from scipy.interpolate import griddata + + # Determine if we're creating a new figure or using existing axis + create_new_figure = (ax is None) + multiExploration = len(ExplorationList) > 1 + # Plot Configuration Dictionary + # Controls which contours are displayed and their visual properties + plot_config = { + # Combined parameter group contours (intersection of individual uncertainties) + 'combined': { + 'gravity': False, # Combined k + h Love number constraints + 'induction': False, # Combined induction response constraints + 'all_data': True, # All constraints combined (gravity + induction) + }, + # Visual properties + 'alpha': { + 'individual': 0.2, # Transparency for individual contours + 'combined': 0.4, # Higher transparency for combined contours (more prominent) + } + } + # Configurable variables (will make these parameters later) + induction_x_color = 'orange' + induction_y_color = 'orange' + induction_z_color = 'darkred' + klove_color = 'blue' + hlove_color = 'green' + gravity_color = 'purple' # Blended color for combined gravity constraints + induction_color = 'darkorange' # Blended color for combined induction constraints + all_data_color = 'darkviolet' # Color for all data combined + true_model_color = 'black' + true_model_marker = '*' + true_model_size = 200 + contour_alpha = plot_config['alpha']['individual'] + combined_alpha = plot_config['alpha']['combined'] + figure_size = (3, 4) + legend_font_size = 10 + title_font_size = 14 + interpolation_factor = 10 # Factor to increase grid resolution for smoother contours + + # Set up exploration data + Exploration = ExplorationList[0] + Exploration.zName = Exploration.xName + FigLbl.SetExploration(Exploration.base.bodyname, Exploration.xName, + Exploration.yName, Exploration.zName) + + # Create figure and axis (only if not provided) + if create_new_figure: + fig = plt.figure(figsize=figure_size) + grid = GridSpec(1, 1) + ax = fig.add_subplot(grid[0, 0]) + if Style.GRIDS: + ax.grid() + ax.set_axisbelow(True) + else: + # Using provided axis + if Style.GRIDS: + ax.grid() + ax.set_axisbelow(True) + + # Set up basic plot title + if create_new_figure: + # For standalone plots, use full title with "Uncertainty Analysis" + if hasattr(Params, 'TITLES') and Params.TITLES: + ax.set_title(f'{Exploration.base.bodyname} Uncertainty Analysis', fontsize=title_font_size) + else: + # For subplots, use custom planet name or body name with x/y variables + if planet_name: + title = f'{planet_name}' + else: + title = f'{Exploration.base.bodyname}' + + # Add x and y variable info to subtitle + if hasattr(FigLbl, 'xLabelExplore') and hasattr(FigLbl, 'yLabelExplore'): + title += f'\n{FigLbl.xLabelExplore} vs {FigLbl.yLabelExplore}' + + ax.set_title(title, fontsize=title_font_size-2) # Slightly smaller for subplots + legend_elements = [] + for i, Exploration in enumerate(ExplorationList): + # Extract x and y data from exploration + data = extract_and_validate_plot_data(result_obj=Exploration, x_field=Exploration.xName, y_field=Exploration.yName, + x_multiplier=FigLbl.xMultExplore, y_multiplier=FigLbl.yMultExplore, + custom_x_axis=FigLbl.xCustomAxis, custom_y_axis=FigLbl.yCustomAxis) + x_data = data['x'].reshape(data['original_shape']) + y_data = data['y'].reshape(data['original_shape']) + + # Create higher resolution grid for smoother contours + x_interp, y_interp = _create_interpolated_grid(x_data, y_data, interpolation_factor) + + ax.set_xlabel(FigLbl.xLabelExplore) + ax.set_ylabel(FigLbl.yLabelExplore) + ax.set_xscale(FigLbl.xScaleExplore) + ax.set_yscale(FigLbl.yScaleExplore) + + + # Compute interpolation-aware uncertainty regions for combined contours + uncertainty_regions = _compute_interpolated_uncertainty_regions( + Exploration, Params, x_data, y_data, x_interp, y_interp + ) + + # Define individual contour specifications + individual_specs = [ + { + 'data': Exploration.base.kLoveAmp, + 'color': klove_color, + 'label': 'k Love Number', + 'best_value': Params.Inversion.kLoveAmp, + 'uncertainty': Params.Inversion.kLoveAmpUncertainity, + 'config_key': 'k_love', + 'group': 'gravity' + }, + { + 'data': Exploration.base.hLoveAmp, + 'color': hlove_color, + 'label': 'h Love Number', + 'best_value': Params.Inversion.hLoveAmp, + 'uncertainty': Params.Inversion.hLoveAmpUncertainity, + 'config_key': 'h_love', + 'group': 'gravity' + }, + { + 'data': Exploration.induction.Bi1x_nT[0], + 'color': induction_x_color, + 'label': 'Bi1x (Orbital)', + 'best_value': Params.Inversion.Bi1xyz_nT['x'][0], + 'uncertainty': Params.Inversion.InductionResponseUncertainty_nT, + 'config_key': 'induction_orbital', + 'group': 'induction' + }, + { + 'data': Exploration.induction.Bi1x_nT[1], + 'color': 'red', + 'label': 'Bi1x (Synodic)', + 'best_value': Params.Inversion.Bi1xyz_nT['x'][1], + 'uncertainty': Params.Inversion.InductionResponseUncertainty_nT, + 'config_key': 'induction_synodic', + 'group': 'induction' + }] + + # Note: Additional induction components can be enabled by setting their config flags to True + + + # Plot individual uncertainty contours (if enabled) + if not np.any([plot_config['combined'][key] for key in plot_config['combined']]): + for spec in individual_specs: + if (spec['data'] is not None): + + contour_fill = _plot_uncertainty_contour( + ax, x_data, y_data, spec['data'], + spec['best_value'], spec['uncertainty'], + spec['color'], contour_alpha, x_interp, y_interp + ) + if contour_fill is not None: + legend_elements.append(plt.Rectangle((0,0),1,1, fc=spec['color'], + alpha=contour_alpha, label=spec['label'])) + + # Plot combined uncertainty contours (if enabled) + combined_specs = [ + { + 'region': uncertainty_regions['gravity'], + 'color': gravity_color, + 'label': 'Gravity Constraints (k+h Love)', + 'config_key': 'gravity' + }, + { + 'region': uncertainty_regions['induction'], + 'color': induction_color, + 'label': 'Induction Constraints', + 'config_key': 'induction' + }, + { + 'region': uncertainty_regions['all_data'], + 'color': all_data_color, + 'label': 'All Constraints Combined', + 'config_key': 'all_data' + } + ] + + for spec in combined_specs: + if (spec['region'] is not None and + plot_config['combined'].get(spec['config_key'], False)): + + if multiExploration: + label = Params.Explore.titleName[i] + color = Params.Explore.color[i] + else: + label = spec['label'] + color = spec['color'] + contour_fill = _plot_boolean_contour( + ax, x_interp, y_interp, spec['region'], + color, combined_alpha + ) + legend_elements.append(plt.Rectangle((0,0),1,1, fc=color, ec=color, + alpha=combined_alpha, label=label)) + + + ax.set_xlim(np.min(x_data), np.max(x_data)) + ax.set_ylim(np.min(y_data), np.max(y_data)) + # Set minor grid lines with low alpha + ax.grid(True, which='minor', alpha=0.1, linestyle='-', linewidth=0.5) + ax.grid(True, which='major', alpha=0.5, linestyle='-', linewidth=0.5) + # Add marker for true model location if available + true_marker = ax.scatter(Params.Inversion.xBestFit, Params.Inversion.yBestFit, c=true_model_color, + marker=true_model_marker, s=true_model_size, + zorder=5, label='True Europa', + edgecolors='white', linewidth=2) + legend_elements.append(true_marker) + + # Add legend (only for standalone plots or first subplot) + if legend_elements: + ax.legend(handles=legend_elements, loc='best', fontsize=legend_font_size, + framealpha=0.9) + # Save and show only if we created a new figure + if create_new_figure: + # Tight layout and save (if file path is available) + plt.tight_layout() + + # Save figure if Params has figure files configured + if hasattr(Params, 'FigureFiles') and hasattr(Params.FigureFiles, 'path'): + fig_path = f"{Params.FigureFiles.path}/uncertainty_plot.pdf" + fig.savefig(fig_path, format='pdf', dpi=600) + print(f"Uncertainty plot saved to: {fig_path}") + + plt.show() + return fig, ax + else: + # Return the axis for multi-subplot usage + return ax + + +def _create_interpolated_grid(x_data, y_data, interpolation_factor): + """ + Create a higher resolution interpolated grid for smoother contours. + + Args: + x_data: Original x coordinate array (2D) + y_data: Original y coordinate array (2D) + interpolation_factor: Factor to increase grid resolution + + Returns: + x_interp, y_interp: Higher resolution coordinate arrays + """ + # Get original grid dimensions + nx, ny = x_data.shape + + # Create higher resolution grid + new_nx = nx * interpolation_factor + new_ny = ny * interpolation_factor + + # Create new coordinate arrays + x_min, x_max = np.min(x_data), np.max(x_data) + y_min, y_max = np.min(y_data), np.max(y_data) + + x_new = np.linspace(x_min, x_max, new_nx) + y_new = np.linspace(y_min, y_max, new_ny) + + x_interp, y_interp = np.meshgrid(x_new, y_new, indexing='ij') + + return x_interp, y_interp + + +def _interpolate_z_data(x_data, y_data, z_data, x_interp, y_interp): + """ + Interpolate z data onto higher resolution grid using linear interpolation. + + Args: + x_data, y_data: Original coordinate arrays (2D) + z_data: Original z data to interpolate + x_interp, y_interp: Target interpolation grid + + Returns: + z_interp: Interpolated z data on higher resolution grid + """ + from scipy.interpolate import RectBivariateSpline + xRow = x_data[:, 0] + yRow = y_data[0, :] + xInterpRow = x_interp[:, 0] + yInterpRow = y_interp[0, :] + + # Handle different dimensionalities of z_data + if z_data.ndim == 3: # (nexc, ny, nx) + nexc, ny, nx = z_data.shape + # Initialize interpolated array with correct dtype + if np.iscomplexobj(z_data): + z_interp = np.zeros((nexc, x_interp.shape[0], x_interp.shape[1]), dtype=complex) + else: + z_interp = np.zeros((nexc, x_interp.shape[0], x_interp.shape[1]), dtype=float) + + for i in range(nexc): + z_slice = z_data[i, :, :] + + # Handle complex data by interpolating real and imaginary parts separately + if np.iscomplexobj(z_slice): + splineReal = RectBivariateSpline(xRow, yRow, np.real(z_slice), kx=3, ky=3) + splineImag = RectBivariateSpline(xRow, yRow, np.imag(z_slice), kx=3, ky=3) + real_interp = splineReal(xInterpRow, yInterpRow) + imag_interp = splineImag(xInterpRow, yInterpRow) + + z_interp_flat = real_interp + 1j * imag_interp + else: + spline = RectBivariateSpline(xRow, yRow, z_slice, kx=3, ky=3) + z_interp_flat = spline(xInterpRow, yInterpRow) + + z_interp[i, :, :] = z_interp_flat + + else: # 2D data (ny, nx) + # Handle complex data by interpolating real and imaginary parts separately + if np.iscomplexobj(z_data): + zReal = np.real(z_data) + zImag = np.imag(z_data) + splineReal = RectBivariateSpline(xRow, yRow, zReal, kx=3, ky=3) + splineImag = RectBivariateSpline(xRow, yRow, zImag, kx=3, ky=3) + real_interp = splineReal(xInterpRow, yInterpRow) + imag_interp = splineImag(xInterpRow, yInterpRow) + z_interp = real_interp + 1j * imag_interp + else: + spline = RectBivariateSpline(xRow, yRow, z_data, kx=3, ky=3) + z_interp = spline(xInterpRow, yInterpRow) + + return z_interp + + +def _extract_contour_data(grid_data, best_value, uncertainty): + """ + Extract and process data for contour plotting, handling complex and 3D data. + + Args: + grid_data: The exploration grid data + best_value: Best-fit value(s) + uncertainty: Uncertainty range + + Returns: + Processed 2D array suitable for contouring + """ + if grid_data is None: + return None + + # Handle complex data by taking magnitude + if np.iscomplexobj(grid_data) or np.iscomplexobj(best_value): + if grid_data.ndim == 3: # (nexc, nx, ny) + # For 3D complex data, compute RMS magnitude across excitations + grid_magnitude = np.sqrt(np.mean(np.abs(grid_data)**2, axis=0)) + else: + grid_magnitude = np.abs(grid_data) + return grid_magnitude + else: + # For real data, return as-is + if grid_data.ndim == 3: # (nexc, nx, ny) + # For 3D real data, compute RMS across excitations + grid_rms = np.sqrt(np.mean(grid_data**2, axis=0)) + return grid_rms + else: + return grid_data + + +def _plot_uncertainty_contour(ax, x_data, y_data, z_data, best_value, uncertainty, color, alpha, x_interp=None, y_interp=None): + """ + Plot a single uncertainty contour with proper handling of complex/3D data. + Uses interpolation for higher fidelity contours if interpolated grids are provided. + + Args: + ax: Matplotlib axis + x_data, y_data: Original coordinate arrays + z_data: Data to contour + best_value: Best-fit value (scalar or array) + uncertainty: Uncertainty range + color: Contour color + alpha: Transparency + x_interp, y_interp: Optional higher resolution interpolated grids + + Returns: + Matplotlib contour object or None + """ + # Use interpolated grids if provided, otherwise use original + if x_interp is not None and y_interp is not None: + x_plot = x_interp + y_plot = y_interp + z_plot = _interpolate_z_data(x_data, y_data, z_data, x_interp, y_interp) + else: + x_plot = x_data + y_plot = y_data + z_plot = z_data + + def _plot_single_contour(data_slice, best_val, alpha_val): + """Helper function to plot a single contour for real data.""" + lower_level = best_val - uncertainty + upper_level = best_val + uncertainty + contour = ax.contourf(x_plot, y_plot, data_slice, + levels=[lower_level, upper_level], + colors=[color], alpha=alpha_val, zorder=2) + contourOutline = ax.contour(x_plot, y_plot, data_slice, + levels=contour.levels, # Contour at value 1.0 + colors=[color], alpha=0.8, linewidths=2) + return contour + + def _plot_complex_contours(data_slice, best_val_slice, alpha_val): + """Helper function to plot contours for complex data (overlap of real and imaginary parts).""" + # Extract real and imaginary parts + real_data = np.real(data_slice) + imag_data = np.imag(data_slice) + best_real = np.real(best_val_slice) + best_imag = np.imag(best_val_slice) + + # Create masks for uncertainty regions + real_mask = np.abs(real_data - best_real) <= uncertainty + imag_mask = np.abs(imag_data - best_imag) <= uncertainty + + # Find overlap region where both real and imaginary parts are within uncertainty + overlap_mask = real_mask & imag_mask + + # Create a new z variable for the overlap region + z_overlap = np.where(overlap_mask, 1.0, 0.0) + + # Plot contour of the overlap region + contour = ax.contourf(x_plot, y_plot, z_overlap, + levels=[0.5, 1.5], # Contour at value 1.0 + colors=[color], alpha=alpha_val, zorder=2) + contourOutline = ax.contour(x_plot, y_plot, z_overlap, + levels=contour.levels, # Contour at value 1.0 + colors=[color], alpha=0.8, linewidths=2) + return [contour] + + contours = [] + + # Check if data is 3D + if z_plot.ndim == 3: # (nexc, nx, ny) + # Iterate over each row of 3D data + for i in range(z_plot.shape[0]): + z_slice = z_plot[i, :, :] + best_val_slice = best_value[i] if hasattr(best_value, '__len__') else best_value + + if np.iscomplexobj(z_slice): + contours.extend(_plot_complex_contours(z_slice, best_val_slice, alpha/2)) + else: + contours.append(_plot_single_contour(z_slice, best_val_slice, alpha)) + else: + # 2D data + if np.iscomplexobj(z_plot): + contours.extend(_plot_complex_contours(z_plot, best_value, alpha/2)) + else: + best_mag = np.abs(best_value) + contours.append(_plot_single_contour(z_plot, best_mag, alpha)) + return contours[0] if len(contours) == 1 else contours diff --git a/PlanetProfile/Inversion/defaultConfigInversion.py b/PlanetProfile/Inversion/defaultConfigInversion.py new file mode 100644 index 00000000..ffef980e --- /dev/null +++ b/PlanetProfile/Inversion/defaultConfigInversion.py @@ -0,0 +1,17 @@ +""" Configuration settings specific to inversion calculations """ +import numpy as np +from PlanetProfile.Utilities.defineStructs import InversionParamsStruct + +configInversionVersion = 1 # Integer number for config file version. Increment when new settings are added to the default config file. + +def inversionAssign(): + """ + Assign inversion parameters for PlanetProfile runs. + """ + InversionParams = InversionParamsStruct() + InversionParams.spacecraftUncertainties = \ + {'Clipper': {'InductionResponseUncertainty_nT': 1.5, 'kLoveAmpUncertainity': 0.018, 'hLoveAmpUncertainity': 0.1}} + InversionParams.setSpaceCraft('Clipper') + return InversionParams + + \ No newline at end of file diff --git a/PlanetProfile/MagneticInduction/MagneticInduction.py b/PlanetProfile/MagneticInduction/MagneticInduction.py index 57e6c235..4594cb0d 100644 --- a/PlanetProfile/MagneticInduction/MagneticInduction.py +++ b/PlanetProfile/MagneticInduction/MagneticInduction.py @@ -7,20 +7,20 @@ from glob import glob as FilesMatchingPattern from scipy.io import savemat, loadmat from PlanetProfile import _Test, _Defaults -from PlanetProfile.Utilities.defineStructs import Constants, EOSlist +from PlanetProfile.Utilities.defineStructs import Constants, EOSlist, Timing from PlanetProfile.MagneticInduction.Moments import Excitations from PlanetProfile.GetConfig import FigMisc, SigParams from MoonMag.asymmetry_funcs import read_Benm as GetBenm, BiList as BiAsym, get_chipq_from_CSpq_single as GeodesyNorm2chipq, \ get_all_Xid as LoadXid, get_rsurf as GetrSurf, norm4pi as normFactor_4pi from MoonMag.symmetry_funcs import InducedAeList as AeList - +import time # Assign logger log = logging.getLogger('PlanetProfile') def MagneticInduction(Planet, Params, fNameOverride=None): """ Calculate induced magnetic moments for the body and prints them to disk. """ - + Timing.setFunctionTime(time.time()) SKIP = False if Planet.Do.VALID and Params.CALC_NEW_INDUCT: # Set Magnetic struct layer arrays as we need for induction calculations @@ -66,7 +66,7 @@ def MagneticInduction(Planet, Params, fNameOverride=None): Planet.Magnetic.sigmaIonosPedersen_Sm = [np.nan] Planet.Magnetic.calcedExc = [] else: - if not (Params.DO_INDUCTOGRAM or Params.DO_EXPLOREOGRAM or Params.INVERSION_IN_PROGRESS): + if not (Params.DO_INDUCTOGRAM or Params.DO_EXPLOREOGRAM or Params.INVERSION_IN_PROGRESS or Params.MONTECARLO_IN_PROGRESS): if Params.PLOT_MAG_SPECTRUM: Planet = FourierSpectrum(Planet, Params) @@ -80,6 +80,7 @@ def MagneticInduction(Planet, Params, fNameOverride=None): # Must return both Planet and Params in order to use common infrastructure # for unpacking parallel runs + Timing.printFunctionTimeDifference('MagneticInduction()', time.time()) return Planet, Params @@ -155,25 +156,25 @@ def CalcInducedMoments(Planet, Params): Planet.Magnetic.Aen[SCera][:,1], Planet.Magnetic.Amp[SCera], AeArg[SCera] \ = AeList(Planet.Magnetic.rSigChange_m, Planet.Magnetic.sigmaLayers_Sm, Planet.Magnetic.omegaExc_radps[SCera], 1/Planet.Bulk.R_m, nn=1, writeout=False, - do_parallel=Params.DO_PARALLEL and not (Params.INDUCTOGRAM_IN_PROGRESS or Params.DO_EXPLOREOGRAM)) + do_parallel=Params.DO_PARALLEL and not (Params.INDUCTOGRAM_IN_PROGRESS or Params.DO_EXPLOREOGRAM or Params.INVERSION_IN_PROGRESS or Params.MONTECARLO_IN_PROGRESS)) Planet.Magnetic.phase[SCera] = -np.degrees(AeArg[SCera]) for n in range(2, Planet.Magnetic.nprmMax): Planet.Magnetic.Aen[SCera][:,n], _, _ \ = AeList(Planet.Magnetic.rSigChange_m, Planet.Magnetic.sigmaLayers_Sm, Planet.Magnetic.omegaExc_radps[SCera], 1/Planet.Bulk.R_m, nn=n, writeout=False, - do_parallel=Params.DO_PARALLEL and not (Params.INDUCTOGRAM_IN_PROGRESS or Params.DO_EXPLOREOGRAM)) + do_parallel=Params.DO_PARALLEL and not (Params.INDUCTOGRAM_IN_PROGRESS or Params.DO_EXPLOREOGRAM or Params.INVERSION_IN_PROGRESS or Params.MONTECARLO_IN_PROGRESS)) else: Planet.Magnetic.Aen[:,1], Planet.Magnetic.Amp, AeArg \ = AeList(Planet.Magnetic.rSigChange_m, Planet.Magnetic.sigmaLayers_Sm, Planet.Magnetic.omegaExc_radps, 1/Planet.Bulk.R_m, nn=1, writeout=False, - do_parallel=Params.DO_PARALLEL and not (Params.INDUCTOGRAM_IN_PROGRESS or Params.DO_EXPLOREOGRAM)) + do_parallel=Params.DO_PARALLEL and not (Params.INDUCTOGRAM_IN_PROGRESS or Params.DO_EXPLOREOGRAM or Params.INVERSION_IN_PROGRESS or Params.MONTECARLO_IN_PROGRESS)) Planet.Magnetic.phase = -np.degrees(AeArg) for n in range(2, Planet.Magnetic.nprmMax): Planet.Magnetic.Aen[:,n], _, _ \ = AeList(Planet.Magnetic.rSigChange_m, Planet.Magnetic.sigmaLayers_Sm, Planet.Magnetic.omegaExc_radps, 1/Planet.Bulk.R_m, nn=n, writeout=False, - do_parallel=Params.DO_PARALLEL and not (Params.INDUCTOGRAM_IN_PROGRESS or Params.DO_EXPLOREOGRAM)) + do_parallel=Params.DO_PARALLEL and not (Params.INDUCTOGRAM_IN_PROGRESS or Params.DO_EXPLOREOGRAM or Params.INVERSION_IN_PROGRESS or Params.MONTECARLO_IN_PROGRESS )) if Params.CALC_ASYM: # Use a separate function for evaluating asymmetric induced moments, as Binm is not as simple as @@ -185,7 +186,7 @@ def CalcInducedMoments(Planet, Params): Planet.Magnetic.gravShape_m, Planet.Magnetic.Benm_nT[SCera], 1/Planet.Bulk.R_m, Planet.Magnetic.nLin, Planet.Magnetic.mLin, Planet.Magnetic.pMax, nprm_max=Planet.Magnetic.nprmMax, writeout=False, - do_parallel=Params.DO_PARALLEL and not (Params.INDUCTOGRAM_IN_PROGRESS or Params.DO_EXPLOREOGRAM or Params.INVERSION_IN_PROGRESS), + do_parallel=Params.DO_PARALLEL and not (Params.INDUCTOGRAM_IN_PROGRESS or Params.DO_EXPLOREOGRAM or Params.INVERSION_IN_PROGRESS or Params.MONTECARLO_IN_PROGRESS), Xid=Planet.Magnetic.Xid) else: Planet.Magnetic.Binm_nT = BiAsym(Planet.Magnetic.rSigChange_m, Planet.Magnetic.sigmaLayers_Sm, @@ -193,7 +194,7 @@ def CalcInducedMoments(Planet, Params): Planet.Magnetic.gravShape_m, Planet.Magnetic.Benm_nT, 1/Planet.Bulk.R_m, Planet.Magnetic.nLin, Planet.Magnetic.mLin, Planet.Magnetic.pMax, nprm_max=Planet.Magnetic.nprmMax, writeout=False, - do_parallel=Params.DO_PARALLEL and not (Params.INDUCTOGRAM_IN_PROGRESS or Params.DO_EXPLOREOGRAM or Params.INVERSION_IN_PROGRESS), + do_parallel=Params.DO_PARALLEL and not (Params.INDUCTOGRAM_IN_PROGRESS or Params.DO_EXPLOREOGRAM or Params.INVERSION_IN_PROGRESS or Params.MONTECARLO_IN_PROGRESS), Xid=Planet.Magnetic.Xid) else: # Multiply complex response by Benm to get Binm for spherically symmetric case @@ -237,6 +238,7 @@ def CalcInducedMoments(Planet, Params): 'y': Bey * np.conj(Planet.Magnetic.Aen[SCera][:,1]), 'z': Bez * np.conj(Planet.Magnetic.Aen[SCera][:,1]) } + Planet.Magnetic.Bi1Tot_nT[SCera] = np.sqrt(Planet.Magnetic.Bi1xyz_nT[SCera]['x']**2 + Planet.Magnetic.Bi1xyz_nT[SCera]['y']**2 + Planet.Magnetic.Bi1xyz_nT[SCera]['z']**2) else: Bex, Bey, Bez = Benm2absBexyz(Planet.Magnetic.Benm_nT) Planet.Magnetic.Bi1xyz_nT = { @@ -244,12 +246,13 @@ def CalcInducedMoments(Planet, Params): 'y': Bey * np.conj(Planet.Magnetic.Aen[:,1]), 'z': Bez * np.conj(Planet.Magnetic.Aen[:,1]) } + Planet.Magnetic.Bi1Tot_nT = np.sqrt(Planet.Magnetic.Bi1xyz_nT['x']**2 + Planet.Magnetic.Bi1xyz_nT['y']**2 + Planet.Magnetic.Bi1xyz_nT['z']**2) Planet.Magnetic.calcedExc = [key for key, CALCED in Params.Induct.excSelectionCalc.items() if CALCED and key in Excitations.Texc_hr[Planet.bodyname].keys() and Excitations.Texc_hr[Planet.bodyname][key] is not None] # Save calculated magnetic moments to disk - if (not Params.NO_SAVEFILE) and (not Params.INVERSION_IN_PROGRESS): + if (not Params.NO_SAVEFILE) and (not Params.INVERSION_IN_PROGRESS or not Params.MONTECARLO_IN_PROGRESS): saveDict = { 'Benm_nT': Planet.Magnetic.Benm_nT, 'Binm_nT': Planet.Magnetic.Binm_nT, @@ -262,6 +265,7 @@ def CalcInducedMoments(Planet, Params): 'Bi1x_nT': Planet.Magnetic.Bi1xyz_nT['x'], 'Bi1y_nT': Planet.Magnetic.Bi1xyz_nT['y'], 'Bi1z_nT': Planet.Magnetic.Bi1xyz_nT['z'], + 'Bi1Tot_nT': Planet.Magnetic.Bi1Tot_nT, 'Aen': Planet.Magnetic.Aen, 'R_km': Planet.Bulk.R_m/1e3, 'asymShape_m': Planet.Magnetic.asymShape_m, @@ -291,6 +295,7 @@ def ReloadMoments(Planet, momentsFile): 'y': reload['Bi1y_nT'][0], 'z': reload['Bi1z_nT'][0] } + Planet.Magnetic.Bi1Tot_nT = reload['Bi1Tot_nT'][0] Planet.Magnetic.Aen = reload['Aen'] Planet.Magnetic.asymShape_m = reload['asymShape_m'] Planet.Magnetic.ionosBounds_m = reload['ionosBounds_m'][0] @@ -310,7 +315,7 @@ def ReloadMoments(Planet, momentsFile): Planet.Magnetic.Amp *= dipScaling for comp in ['x', 'y', 'z']: Planet.Magnetic.Bi1xyz_nT[comp] *= dipScaling - + Planet.Magnetic.Bi1Tot_nT *= dipScaling for n in range(1, np.max(Planet.Magnetic.nLin)+1): scaling = (Planet.Bulk.R_m / Rre_m)**(n+2) Planet.Magnetic.Aen[:, n] *= scaling @@ -469,8 +474,9 @@ def SetupInduction(Planet, Params): Sets Planet attributes: Magnetic.rSigChange_m, Magnetic.sigmaLayers_Sm, Magnetic.asymShape_m """ - - if not (not Params.CALC_NEW and Params.DO_INDUCTOGRAM): + # Exploration, inversion, and inductograms do not need to set up all these settings at the start + SKIP_LAYER_SETUP = (Params.DO_INDUCTOGRAM or Params.DO_EXPLOREOGRAM or Params.DO_MONTECARLO) and not (Params.INDUCTOGRAM_IN_PROGRESS or Params.INVERSION_IN_PROGRESS or Params.MONTECARLO_IN_PROGRESS or Params.EXPLOREOGRAM_IN_PROGRESS) + if not SKIP_LAYER_SETUP: # If we are recalculating the induction from a previously run inductogram, we can skip this step since Planet.phase will not be initialized # Lots of errors can happen if we haven't calculated the electrical conductivity, # so we make this contingent on having it. @@ -486,80 +492,80 @@ def SetupInduction(Planet, Params): if Params.CALC_CONDUCT and Planet.Do.VALID: # Reconfigure layer conducting boundaries as needed. # For inductOtype == 'sigma', we have already set these arrays. - if not (Params.Induct.inductOtype == 'sigma' and Params.DO_INDUCTOGRAM): - if Params.CALC_NEW or not Params.DO_INDUCTOGRAM: - # Append optional ionosphere - # We first check if these are unset here, then assign them to what they should be if unset - if Planet.Magnetic.ionosBounds_m is None or Planet.Magnetic.sigmaIonosPedersen_Sm is None: - Planet.Magnetic.ionosBounds_m = [np.nan] - Planet.Magnetic.sigmaIonosPedersen_Sm = [np.nan] - # Now, we handle unset ionospheres - if np.all(np.isnan(Planet.Magnetic.ionosBounds_m)) or np.all(np.isnan(Planet.Magnetic.sigmaIonosPedersen_Sm)): - # Make sure the arrays are both just length 1 of nan - Planet.Magnetic.ionosBounds_m = [np.nan] - Planet.Magnetic.sigmaIonosPedersen_Sm = [np.nan] - zIonos_m = [] - sigmaIonos_Sm = [] + if not Params.Induct.inductOtype == 'sigma' and not SKIP_LAYER_SETUP: + # Append optional ionosphere + # We first check if these are unset here, then assign them to what they should be if unset + if Planet.Magnetic.ionosBounds_m is None or Planet.Magnetic.sigmaIonosPedersen_Sm is None: + Planet.Magnetic.ionosBounds_m = [np.nan] + Planet.Magnetic.sigmaIonosPedersen_Sm = [np.nan] + # Now, we handle unset ionospheres + if np.all(np.isnan(Planet.Magnetic.ionosBounds_m)) or np.all(np.isnan(Planet.Magnetic.sigmaIonosPedersen_Sm)): + # Make sure the arrays are both just length 1 of nan + Planet.Magnetic.ionosBounds_m = [np.nan] + Planet.Magnetic.sigmaIonosPedersen_Sm = [np.nan] + zIonos_m = [] + sigmaIonos_Sm = [] + else: + zIonos_m = Planet.Bulk.R_m + np.array(Planet.Magnetic.ionosBounds_m) + sigmaIonos_Sm = np.array(Planet.Magnetic.sigmaIonosPedersen_Sm) + # Allow special case for specifying an ionosphere with 1 conductivity and 2 bounds, when + # there is a substantial neutral atmosphere and the conducting region is at altitude + # (e.g. for Triton) + if np.size(Planet.Magnetic.sigmaIonosPedersen_Sm) == 1 and np.size(Planet.Magnetic.ionosBounds_m) == 2: + sigmaIonos_Sm = np.append(0, sigmaIonos_Sm) + # Flip arrays to be in radial ascending order as needed in induction calculations, then add ionosphere + rLayers_m = np.append(np.flip(Planet.r_m[:-1]), zIonos_m) + sigmaInduct_Sm = np.append(np.flip(Planet.sigma_Sm), sigmaIonos_Sm) + + # Eliminate NaN values and 0 values, assigning them to a default minimum + sigmaInduct_Sm[np.logical_or(np.isnan(sigmaInduct_Sm), sigmaInduct_Sm == 0)] = Constants.sigmaDef_Sm + # Set low conductivities to all be the same default value so we can shrink them down to single layers + sigmaInduct_Sm[sigmaInduct_Sm < Constants.sigmaMin_Sm] = Constants.sigmaDef_Sm + + # Optionally, further reduce computational overhead by shrinking the number of ocean layers modeled + if np.size(indsLiq) != 0 and Params.Sig.REDUCED_INDUCT and not Planet.Do.NO_H2O: + if not np.all(np.diff(indsLiq) == 1): + log.warning('HP ices found in ocean while REDUCED_INDUCT is True. They will be ignored ' + + 'in the interpolation.') + + # Get radius values from D/nIntL above the seafloor to the ice shell + rBot_m = Planet.Bulk.R_m - (Planet.zb_km + Planet.D_km) * 1e3 + rTop_m = rLayers_m[indsLiq[-1]] + rOcean_m = np.linspace(rBot_m, rTop_m, Params.Induct.nIntL+1)[1:] + # Interpolate the conductivities corresponding to those radii + if np.size(indsLiq) == 1: + log.warning(f'Only 1 layer found in ocean, but number of layers to ' + + f'interpolate over is {Params.Induct.nIntL}. Arbitrary layers will be introduced.') + rModel_m = np.concatenate((np.array([rBot_m]), rLayers_m[indsLiq])) + sigmaModel_Sm = np.concatenate((sigmaInduct_Sm[indsLiq] * 1.001, sigmaInduct_Sm[indsLiq])) else: - zIonos_m = Planet.Bulk.R_m + np.array(Planet.Magnetic.ionosBounds_m) - sigmaIonos_Sm = np.array(Planet.Magnetic.sigmaIonosPedersen_Sm) - # Allow special case for specifying an ionosphere with 1 conductivity and 2 bounds, when - # there is a substantial neutral atmosphere and the conducting region is at altitude - # (e.g. for Triton) - if np.size(Planet.Magnetic.sigmaIonosPedersen_Sm) == 1 and np.size(Planet.Magnetic.ionosBounds_m) == 2: - sigmaIonos_Sm = np.append(0, sigmaIonos_Sm) - # Flip arrays to be in radial ascending order as needed in induction calculations, then add ionosphere - rLayers_m = np.append(np.flip(Planet.r_m[:-1]), zIonos_m) - sigmaInduct_Sm = np.append(np.flip(Planet.sigma_Sm), sigmaIonos_Sm) - - # Eliminate NaN values and 0 values, assigning them to a default minimum - sigmaInduct_Sm[np.logical_or(np.isnan(sigmaInduct_Sm), sigmaInduct_Sm == 0)] = Constants.sigmaDef_Sm - # Set low conductivities to all be the same default value so we can shrink them down to single layers - sigmaInduct_Sm[sigmaInduct_Sm < Constants.sigmaMin_Sm] = Constants.sigmaDef_Sm - - # Optionally, further reduce computational overhead by shrinking the number of ocean layers modeled - if np.size(indsLiq) != 0 and Params.Sig.REDUCED_INDUCT and not Planet.Do.NO_H2O: - if not np.all(np.diff(indsLiq) == 1): - log.warning('HP ices found in ocean while REDUCED_INDUCT is True. They will be ignored ' + - 'in the interpolation.') - - # Get radius values from D/nIntL above the seafloor to the ice shell - rBot_m = Planet.Bulk.R_m - (Planet.zb_km + Planet.D_km) * 1e3 - rTop_m = rLayers_m[indsLiq[-1]] - rOcean_m = np.linspace(rBot_m, rTop_m, Params.Induct.nIntL+1)[1:] - # Interpolate the conductivities corresponding to those radii - if np.size(indsLiq) == 1: - log.warning(f'Only 1 layer found in ocean, but number of layers to ' + - f'interpolate over is {Params.Induct.nIntL}. Arbitrary layers will be introduced.') - rModel_m = np.concatenate((np.array([rBot_m]), rLayers_m[indsLiq])) - sigmaModel_Sm = np.concatenate((sigmaInduct_Sm[indsLiq] * 1.001, sigmaInduct_Sm[indsLiq])) - else: - rModel_m = rLayers_m[indsLiq] - sigmaModel_Sm = sigmaInduct_Sm[indsLiq] - sigmaOcean_Sm = spi.interp1d(rModel_m, sigmaModel_Sm, kind=Params.Induct.oceanInterpMethod, - bounds_error=False, fill_value=Constants.sigmaDef_Sm)(rOcean_m) - # Stitch together the r and sigma arrays with the new ocean values - rLayers_m = np.concatenate((rLayers_m[:indsLiq[0]], rOcean_m, rLayers_m[indsLiq[-1]+1:])) - sigmaInduct_Sm = np.concatenate((sigmaInduct_Sm[:indsLiq[0]], sigmaOcean_Sm, sigmaInduct_Sm[indsLiq[-1]+1:])) - - # Get the indices of layers just below where changes happen - iChange = [i for i,sig in enumerate(sigmaInduct_Sm) if sig != np.append(sigmaInduct_Sm, np.nan)[i+1]] - Planet.Magnetic.sigmaLayers_Sm = sigmaInduct_Sm[iChange] - Planet.Magnetic.rSigChange_m = rLayers_m[iChange] + rModel_m = rLayers_m[indsLiq] + sigmaModel_Sm = sigmaInduct_Sm[indsLiq] + sigmaOcean_Sm = spi.interp1d(rModel_m, sigmaModel_Sm, kind=Params.Induct.oceanInterpMethod, + bounds_error=False, fill_value=Constants.sigmaDef_Sm)(rOcean_m) + # Stitch together the r and sigma arrays with the new ocean values + rLayers_m = np.concatenate((rLayers_m[:indsLiq[0]], rOcean_m, rLayers_m[indsLiq[-1]+1:])) + sigmaInduct_Sm = np.concatenate((sigmaInduct_Sm[:indsLiq[0]], sigmaOcean_Sm, sigmaInduct_Sm[indsLiq[-1]+1:])) + + # Get the indices of layers just below where changes happen + iChange = [i for i,sig in enumerate(sigmaInduct_Sm) if sig != np.append(sigmaInduct_Sm, np.nan)[i+1]] + Planet.Magnetic.sigmaLayers_Sm = sigmaInduct_Sm[iChange] + Planet.Magnetic.rSigChange_m = rLayers_m[iChange] if Planet.Magnetic.sigmaScaling is not None: log.debug(f'Applying arbitary scaling factor of {Planet.Magnetic.sigmaScaling} to interior conducting layers.') iOcean = np.logical_and(Planet.Magnetic.rSigChange_m <= Planet.Bulk.R_m - Planet.zb_km, Planet.Magnetic.rSigChange_m > Planet.Bulk.R_m - Planet.D_km) Planet.Magnetic.sigmaLayers_Sm[iOcean] = Planet.Magnetic.sigmaLayers_Sm[iOcean] * Planet.Magnetic.sigmaScaling - - Planet.Magnetic.nBds = np.size(Planet.Magnetic.rSigChange_m) + if not SKIP_LAYER_SETUP: + Planet.Magnetic.nBds = np.size(Planet.Magnetic.rSigChange_m) # Set asymmetric shape if applicable if Params.CALC_ASYM: if Planet.Magnetic.pMax == 0: Params.CALC_ASYM = False log.warning('Magnetic.pMax is 0, asymmetry calculations will be skipped.') - Planet.Magnetic.asymShape_m = np.zeros((Planet.Magnetic.nBds, 2, 1, 1)) + if not SKIP_LAYER_SETUP: + Planet.Magnetic.asymShape_m = np.zeros((Planet.Magnetic.nBds, 2, 1, 1)) else: if Planet.Magnetic.pMax is not None and (Planet.Magnetic.pMax < 2 and not SigParams.ALLOW_LOW_PMAX): log.warning('SigParams.INCLUDE_ASYM is True, but Magnetic.pMax is less than 2. ' + @@ -567,8 +573,9 @@ def SetupInduction(Planet, Params): 'among the largest contributors to asymmetric induction. Magnetic.pMax has been ' + 'increased to 2. Toggle this check with SigParams.ALLOW_LOW_PMAX in configInduct.') Planet.Magnetic.pMax = 2 - Planet = SetAsymShape(Planet, Params) - Planet.Magnetic.nAsymBds = np.size(Planet.Magnetic.zMeanAsym_km) + if not SKIP_LAYER_SETUP: + Planet = SetAsymShape(Planet, Params) + Planet.Magnetic.nAsymBds = np.size(Planet.Magnetic.zMeanAsym_km) # Fetch Xid array XidLabel = f'' @@ -586,10 +593,11 @@ def SetupInduction(Planet, Params): Planet.Magnetic.Xid = EOSlist.loaded[XidLabel] else: Planet.Magnetic.pMax = 0 - Planet.Magnetic.asymShape_m = np.zeros((Planet.Magnetic.nBds, 2, 1, 1)) - + if not SKIP_LAYER_SETUP: + Planet.Magnetic.asymShape_m = np.zeros((Planet.Magnetic.nBds, 2, 1, 1)) + # Get excitation spectrum - if not (Params.DO_INDUCTOGRAM or Params.INVERSION_IN_PROGRESS): + if not (Params.INDUCTOGRAM_IN_PROGRESS or Params.EXPLOREOGRAM_IN_PROGRESS or Params.INVERSION_IN_PROGRESS or Params.MONTECARLO_IN_PROGRESS): # Block if we're doing an inductogram or inversion, so that we only attempt to load once Planet.Magnetic.Texc_hr, Planet.Magnetic.omegaExc_radps, Planet.Magnetic.Benm_nT, \ Planet.Magnetic.B0_nT, Planet.Magnetic.Bexyz_nT \ diff --git a/PlanetProfile/MagneticInduction/Moments.py b/PlanetProfile/MagneticInduction/Moments.py index 9a169905..cfb57900 100644 --- a/PlanetProfile/MagneticInduction/Moments.py +++ b/PlanetProfile/MagneticInduction/Moments.py @@ -1,74 +1,6 @@ import numpy as np -""" Classes for keeping track of excitation and induced magnetic moments """ - -class InductionStruct: - def __init__(self): - self.bodyname = None # Name of body modeled. - self.yName = None # Name of variable along y axis. Options are "Tb", "phi", "rho", "sigma", where the first 3 are vs. salinity, and sigma is vs. thickness. - self.Texc_hr = None # Dict of excitation periods modeled. - self.Amp = None # Amplitude of dipole response (modulus of complex dipole response). - self.phase = None # (Positive) phase delay in degrees. - self.Bix_nT = None # Induced Bx dipole moments relative to body surface in nT for each excitation. - self.Biy_nT = None # Induced By dipole moments relative to body surface in nT for each excitation. - self.Biz_nT = None # Induced Bz dipole moments relative to body surface in nT for each excitation. - self.wOcean_ppt = None # Values of salinity used. - self.oceanComp = None # Ocean composition used. - self.Tb_K = None # Values of Bulk.Tb_K used. - self.rhoSilMean_kgm3 = None # Values of Sil.rhoMean_kgm3 resulted (also equal to those set for all but phi inductOtype). - self.phiRockMax_frac = None # Values of Sil.phiRockMax_frac set. - self.Tmean_K = None # Ocean mean temperature result in K. - self.sigmaMean_Sm = None # Mean ocean conductivity. Used to map plots vs. salinity onto D/sigma plots. - self.sigmaTop_Sm = None # Ocean top conductivity. Used to map plots vs. salinity onto D/sigma plots. - self.D_km = None # Ocean layer thickness in km. Used to map plots vs. salinity onto D/sigma plots. - self.zb_km = None # Upper ice shell thickness in km. - self.R_m = None # Body radius in m, used to scale amplitudes. - self.rBds_m = None # Conducting layer upper boundaries in m. - self.sigmaLayers_Sm = None # Conductivities below each boundary in S/m. - self.zb_approximate_km = None # Upper approximate ice shell thickness in km. - self.oceanComp = None # Ocean compositions - - - self.x = None # Variable to plot on x axis of inductogram plots - self.y = None # Variable to plot on y axis of inductogram plots - self.compsList = None # Linear list of compositions for each model point - self.comps = None # Minimal list of compositions, with 1 entry per comp - self.SINGLE_COMP = None # Boolean flag for tracking if all of the models have the same composition - - def SetAxes(self, inductOtype): - # Set the x and y variables to plot in inductograms based on inductOtype - if inductOtype == 'sigma': - self.x = self.sigmaMean_Sm - self.y = self.D_km - elif inductOtype == 'oceanComp': - self.x = self.oceanComp - self.y = self.zb_approximate_km - else: - self.x = self.wOcean_ppt - if inductOtype == 'Tb': - self.y = self.Tb_K - elif inductOtype == 'rho': - self.y = self.rhoSilMean_kgm3 - elif inductOtype == 'phi': - self.y = self.phiRockMax_frac - else: - raise ValueError(f'inductOtype {inductOtype} not recognized.') - - def SetComps(self, inductOtype): - # Set some attributes pertaining to handling multiple ocean compositions in plots - self.compsList = self.oceanComp.flatten() - # Change any array with CustomSolution to just CustomSolution - for i in range(self.compsList.size): - if 'CustomSolution' in self.compsList[i]: - self.compsList[i] = 'CustomSolution' - if np.all(self.compsList == self.compsList[0]) and inductOtype != 'sigma': - self.SINGLE_COMP = True - self.comps = [self.compsList[0]] - else: - self.SINGLE_COMP = False - self.comps = np.unique(self.compsList) - - +""" Classes for keeping track of excitation moments """ class ExcitationsList: def __init__(self): self.nprmMax = 1 diff --git a/PlanetProfile/Main.py b/PlanetProfile/Main.py index 7bdd4209..24245a67 100644 --- a/PlanetProfile/Main.py +++ b/PlanetProfile/Main.py @@ -8,6 +8,7 @@ # Import necessary Python modules import os, sys, time, importlib import numpy as np +import pickle import logging from scipy.io import savemat, loadmat from copy import deepcopy @@ -15,27 +16,31 @@ from collections.abc import Iterable from os.path import isfile from glob import glob as FilesMatchingPattern +import pandas as pd +import ast # Import all function definitions for this file from PlanetProfile import _Defaults, _TestImport, CopyCarefully from PlanetProfile.GetConfig import Params as configParams, FigMisc -from PlanetProfile.MagneticInduction.MagneticInduction import MagneticInduction, ReloadInduction, GetBexc, Benm2absBexyz -from PlanetProfile.MagneticInduction.Moments import InductionStruct, Excitations as Mag -from PlanetProfile.Plotting.ProfilePlots import GeneratePlots, PlotExploreOgram, PlotExploreOgramDsigma, PlotExploreOgramLoveComparison, PlotExploreOgramZbD -from PlanetProfile.Plotting.MagPlots import GenerateMagPlots, PlotInductOgram, \ - PlotInductOgramPhaseSpace +from PlanetProfile.MagneticInduction.MagneticInduction import MagneticInduction, ReloadInduction, GetBexc, Benm2absBexyz, SetupInduction +from PlanetProfile.MagneticInduction.Moments import Excitations as Mag +from PlanetProfile.Plotting.ProfilePlots import GeneratePlots +from PlanetProfile.Plotting.MagPlots import GenerateMagPlots, GenerateExplorationMagPlots +from PlanetProfile.Plotting.ExplorationPlots import GenerateExplorationPlots from PlanetProfile.Thermodynamics.LayerPropagators import IceLayers, OceanLayers, InnerLayers, GetIceShellTFreeze from PlanetProfile.Thermodynamics.Electrical import ElecConduct from PlanetProfile.Thermodynamics.OceanProps import LiquidOceanPropsCalcs, WriteLiquidOceanProps from PlanetProfile.Thermodynamics.Seismic import SeismicCalcs, WriteSeismic from PlanetProfile.Thermodynamics.Viscosity import ViscosityCalcs -from PlanetProfile.Utilities.defineStructs import Constants, FigureFilesSubstruct, PlanetStruct, EOSlist, ExplorationStruct -from PlanetProfile.Utilities.SetupInit import SetupInit, SetupFilenames, SetCMR2strings +from PlanetProfile.Utilities.defineStructs import Constants, FigureFilesSubstruct, PlanetStruct, Timing +from PlanetProfile.Utilities.ResultsStructs import ExplorationResultsStruct, MonteCarloResultsStruct, InductionResultsStruct +from PlanetProfile.Utilities.SetupInit import SetupInit, SetupFilenames, SetCMR2strings, PrecomputeEOS +from PlanetProfile.Utilities.ResultsIO import WriteResults, ReloadResultsFromPickle, ExtractResults, InductionCalced from PlanetProfile.Thermodynamics.Reaktoro.CustomSolution import SetupCustomSolutionPlotSettings from PlanetProfile.Utilities.PPversion import ppVerNum from PlanetProfile.Gravity.Gravity import GravityParameters -from PlanetProfile.Utilities.SummaryTables import GetLayerMeans, PrintGeneralSummary, PrintLayerSummaryLatex, PrintLayerTableLatex -from PlanetProfile.Utilities.reducedPlanetModel import GetReducedPlanetProfile +from PlanetProfile.Utilities.SummaryTables import GetExplorationComparisons, GetLayerMeans, PrintGeneralSummary, PrintLayerSummaryLatex, PrintLayerTableLatex +from PlanetProfile.Utilities.reducedPlanetModel import GetReducedPlanet # Parallel processing import multiprocessing as mtp @@ -60,7 +65,8 @@ def run(bodyname=None, opt=None, fNames=None): This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.""") - + # Intializing timing structure + Timing.setStartingTime(time.time()) # Copy global Params settings to local variable so we can add things like filenames Params = configParams @@ -80,81 +86,37 @@ def run(bodyname=None, opt=None, fNames=None): if bodyname == '': raise ValueError('A single body must be specified for an InductOgram.') else: - Induction, Params = InductOgram(bodyname, Params, fNameOverride=fNames[0]) + InductionList = [] + FigureFilesList = [] + for fName in fNames: + Induction, Params = InductOgram(bodyname, Params, fNameOverride=fName) + InductionList.append(Induction) + FigureFilesList.append(deepcopy(Params.FigureFiles)) if not Params.SKIP_PLOTS: - Params = SetupCustomSolutionPlotSettings(Induction.oceanComp, Params) - if Params.COMPARE: - inductOgramFiles = FilesMatchingPattern(Params.DataFiles.fNameInductOgramBase+'*.mat') - Params.nModels = np.size(inductOgramFiles) - InductionList = np.empty(Params.nModels, dtype=object) - InductionList[0] = deepcopy(Induction) - # Move the filename for this run to the front of the list - if Params.DataFiles.inductOgramFile in inductOgramFiles: - inductOgramFiles.remove(Params.DataFiles.inductOgramFile) - inductOgramFiles.insert(0, Params.DataFiles.inductOgramFile) - for i, reloadInduct in enumerate(inductOgramFiles[1:]): - InductionList[i+1] = ReloadInductOgram(bodyname, Params, fNameOverride=reloadInduct)[0] - else: - InductionList = [Induction] - PlotInductOgram(InductionList, Params) - PlotInductOgramPhaseSpace(InductionList, Params) + GenerateExplorationMagPlots(InductionList, FigureFilesList, Params) elif Params.DO_EXPLOREOGRAM: if bodyname == '': raise ValueError('A single body must be specified for an ExploreOgram.') else: - if Params.PLOT_INDIVIDUAL_PLANET_PLOTS: - PlanetGrid, Exploration, Params = ExploreOgram(bodyname, Params, RETURN_GRID=True, fNameOverride=fNames[0]) - else: - Exploration, Params = ExploreOgram(bodyname, Params,fNameOverride = fNames[0]) - if not Params.SKIP_PLOTS: - if Params.COMPARE: - exploreOgramFiles = FilesMatchingPattern(os.path.join(Params.DataFiles.fName+'*.mat')) - Params.nModels = np.size(exploreOgramFiles) - ExplorationList = np.empty(Params.nModels, dtype=object) - ExplorationList[0] = deepcopy(Exploration) - # Move the filename for this run to the front of the list - if Params.DataFiles.exploreOgramFile in exploreOgramFiles: - exploreOgramFiles.remove(Params.DataFiles.exploreOgramFile) - exploreOgramFiles.insert(0, Params.DataFiles.exploreOgramFile) - for i, reloadExplore in enumerate(exploreOgramFiles[1:]): - ExplorationList[i+1] = ReloadExploreOgram(bodyname, Params, fNameOverride=reloadExplore)[0] - else: - ExplorationList = [Exploration] - PLOT_EXPLORATION = not (Params.Explore.nx == 1 or Params.Explore.ny == 1) # If either axis is of size 1, then we cannot plot - # Setup CustomSolution plot settings - all_ocean_comps = [] - for Exploration in ExplorationList: - all_ocean_comps.extend(np.array(Exploration.oceanComp).flatten()) - Params = SetupCustomSolutionPlotSettings(np.array(all_ocean_comps), Params) - if PLOT_EXPLORATION: - if isinstance(Params.Explore.zName, list): - figNames = Params.FigureFiles.explore + [] - for zName, figName in zip(Params.Explore.zName, figNames): - for Exploration in ExplorationList: - Exploration.zName = zName - Params.FigureFiles.explore = figName - PlotExploreOgram(ExplorationList, Params) - Params.FigureFiles.explore = figNames + ExplorationList = [] + FigureFilesList = [] + for fName in fNames: + if Params.PLOT_INDIVIDUAL_PLANET_PLOTS: + PlanetGrid, Exploration, Params = ExploreOgram(bodyname, Params, RETURN_GRID=True, fNameOverride=fName) else: - for Exploration in ExplorationList: - Exploration.zName = Params.Explore.zName - PlotExploreOgram(ExplorationList, Params) - # Now we plot the ZbD plots (must plot after exploreogram plots since we change the x and y variables) - if Params.PLOT_Zb_D: - if isinstance(Params.Explore.zName, list): - figNames = Params.FigureFiles.explore + [] - for zName, figName in zip(Params.Explore.zName, figNames): - for Exploration in ExplorationList: - Exploration.zName = zName - Params.FigureFiles.exploreZbD = figName - PlotExploreOgramZbD(ExplorationList, Params) - else: - for Exploration in ExplorationList: - Exploration.zName = Params.Explore.zName - PlotExploreOgramZbD(ExplorationList, Params) - PlotExploreOgramDsigma(ExplorationList, Params) - PlotExploreOgramLoveComparison(ExplorationList, Params) + Exploration, Params = ExploreOgram(bodyname, Params,fNameOverride = fName) + ExplorationList.append(Exploration) + FigureFilesList.append(deepcopy(Params.FigureFiles)) + if not Params.SKIP_PLOTS and not np.all([Exploration.base.VALID == False for Exploration in ExplorationList]): + GenerateExplorationPlots(ExplorationList, FigureFilesList, Params) + if not Params.SKIP_INDUCTION: + GenerateExplorationMagPlots(ExplorationList, FigureFilesList, Params) if Params.PLOT_INDIVIDUAL_PLANET_PLOTS: + for Planet in PlanetGrid.flatten(): + Planet, Params.DataFiles, Params.FigureFiles = SetupFilenames(Planet, Params) + IndividualPlanetList, Params = GetLayerMeans(np.array([Planet]), Params) + GeneratePlots(IndividualPlanetList, Params) + # Get large-scale layer properties, which are needed for table outputs and some plots CompareList = PlanetGrid.flatten() CompareList, Params = GetLayerMeans(CompareList, Params) @@ -164,7 +126,50 @@ def run(bodyname=None, opt=None, fNames=None): Params.FigureFiles = FigureFilesSubstruct(comparePath, compareBase, FigMisc.xtn) GeneratePlots(CompareList, Params) - + + # Plot combined figures + MULTIPLOT = Params.COMPARE and len(ExplorationList) > 1 + if MULTIPLOT: + ExplorationCompareList, Params, FigureFilesList = GetExplorationComparisons(ExplorationList, Params) + if len(ExplorationCompareList) > 0: + for ExplorationCompare, FigureFiles in zip(ExplorationCompareList, FigureFilesList): + Params.FigureFiles = FigureFiles + GenerateExplorationPlots([ExplorationCompare], [FigureFiles], Params) + + elif Params.DO_MONTECARLO: + if bodyname == '': + raise ValueError('A single body must be specified for a Monte Carlo run.') + else: + MCResults, Params = MonteCarlo(bodyname, Params, fNameOverride=fNames[0]) + """# Create plots if requested + if Params.MonteCarlo.showPlots and not Params.SKIP_PLOTS: + # Setup CustomSolution plot settings + all_ocean_comps = [] + for MCResult in [MCResults]: + all_ocean_comps.extend(np.array(MCResult.base.oceanComp).flatten()) + Params = SetupCustomSolutionPlotSettings(np.array(all_ocean_comps), Params) + if Params.PLOT_Zb_D: + if isinstance(Params.Explore.zName, list): + figNames = Params.FigureFiles.exploreZbD + [] + for zName, figName in zip(Params.Explore.zName, figNames): + MCResults.zName = zName + Params.FigureFiles.exploreZbD = figName + PlotExploreOgramZbD([MCResults], Params) + else: + MCResults.zName = Params.Explore.zNameZbD + PlotExploreOgramZbD([MCResults], Params) + # Now we plot the ZbY plots (ice shell thickness vs specified property with ocean thickness as color) + if Params.PLOT_Zb_Y: + if isinstance(Params.Explore.zName, list): + figNames = Params.FigureFiles.exploreZbY + [] + for zName, figName in zip(Params.Explore.zName, figNames): + MCResults.zName = zName + Params.FigureFiles.exploreZbY = figName + PlotExploreOgramXZ([MCResults], Params) + else: + MCResults.zName = Params.Explore.zNameZbY + PlotExploreOgramXZ([MCResults], Params) + PlotMonteCarlo(MCResults, Params)""" else: # Set timekeeping for recording elapsed times @@ -240,6 +245,7 @@ def run(bodyname=None, opt=None, fNames=None): PrintLayerTableLatex(CompareList, Params) if Params.DISP_TABLE: PrintGeneralSummary(CompareList, Params) + Timing.printTotalTimeDifference('Total run time for PlanetProfile: ') return """ END MAIN RUN BLOCK """ @@ -262,7 +268,7 @@ def PlanetProfile(Planet, Params): # Save data after modeling if (not Params.NO_SAVEFILE) and Planet.Do.VALID and (not Params.INVERSION_IN_PROGRESS): WriteProfile(Planet, Params) - if not Planet.Do.NO_OCEAN: + if Params.CALC_OCEAN_PROPS and not Planet.Do.NO_OCEAN: WriteLiquidOceanProps(Planet, Params) if Params.CALC_SEISMIC and not Params.SKIP_INNER: WriteSeismic(Planet, Params) @@ -272,7 +278,7 @@ def PlanetProfile(Planet, Params): # Main plotting functions if ((not Params.SKIP_PLOTS) and not ( - Params.DO_INDUCTOGRAM or Params.DO_EXPLOREOGRAM or Params.INVERSION_IN_PROGRESS)) \ + Params.DO_INDUCTOGRAM or Params.DO_EXPLOREOGRAM or Params.INVERSION_IN_PROGRESS or Params.DO_MONTECARLO)) \ and Planet.Do.VALID: # Calculate large-scale layer properties PlanetList, Params = GetLayerMeans(np.array([Planet]), Params) @@ -281,13 +287,12 @@ def PlanetProfile(Planet, Params): GeneratePlots(PlanetList, Params) Planet = PlanetList[0] # Create a simplified reduced planet structure for magnetic induction and/or gravity calculations - if Planet.Do.VALID and (not Params.SKIP_INDUCTION or not Params.SKIP_GRAVITY): - Planet, Params = GetReducedPlanetProfile(Planet, Params) + if (Planet.Do.VALID or (Params.ALLOW_BROKEN_MODELS and Planet.Do.STILL_CALCULATE_BROKEN_PROPERTIES)) and (not Params.SKIP_INDUCTION or not Params.SKIP_GRAVITY): + Planet, Params = GetReducedPlanet(Planet, Params) # Magnetic induction calculations and plots - if (Params.CALC_CONDUCT and Planet.Do.VALID) and not Params.SKIP_INDUCTION: + if (Params.CALC_CONDUCT and (Planet.Do.VALID or (Params.ALLOW_BROKEN_MODELS and Planet.Do.STILL_CALCULATE_BROKEN_PROPERTIES))) and not Params.SKIP_INDUCTION: # Calculate induced magnetic moments Planet, Params = MagneticInduction(Planet, Params) - # Plot induced dipole surface strength if Planet.Magnetic.Binm_nT is None: log.warning('Tried to GenerateMagPlots, but Magnetic.Binm_nT is None. ' + @@ -295,15 +300,14 @@ def PlanetProfile(Planet, Params): 'set to False. Try to re-run with CALC_NEW_INDUCT set to True in ' 'configPP.py.') elif (not Params.SKIP_PLOTS) and \ - not (Params.DO_INDUCTOGRAM or Params.DO_EXPLOREOGRAM or Params.INVERSION_IN_PROGRESS): + not (Params.DO_INDUCTOGRAM or Params.DO_EXPLOREOGRAM or Params.INVERSION_IN_PROGRESS or Params.MONTECARLO_IN_PROGRESS): GenerateMagPlots([Planet], Params) - # Gravity calcuations and plots - if (Params.CALC_SEISMIC and Params.CALC_VISCOSITY and Planet.Do.VALID) and not Params.SKIP_GRAVITY: + if (Params.CALC_SEISMIC and Params.CALC_VISCOSITY) and (Planet.Do.VALID or (Params.ALLOW_BROKEN_MODELS and Planet.Do.STILL_CALCULATE_BROKEN_PROPERTIES)) and not Params.SKIP_GRAVITY: # Calculate gravity parameters Planet, Params = GravityParameters(Planet, Params) - - PrintCompletion(Planet, Params) + if Params.PRINT_COMPLETION: + PrintCompletion(Planet, Params) return Planet, Params @@ -311,7 +315,8 @@ def HydroOnly(Planet, Params): """ Wrapper for PlanetProfile function, up through hydrosphere calculations, for parameter exploration that needs only to redo the interior. """ - + # Reset profile start time to now + Planet.profileStartTime = time.time() Planet, Params = SetupInit(Planet, Params) if not Planet.Do.NO_H2O: Planet = IceLayers(Planet, Params) @@ -324,7 +329,8 @@ def InteriorEtc(Planet, Params): """ Wrapper for PlanetProfile function, minus hydrosphere calculations, for parameter exploration that needs only to redo the interior. """ - + # Reset profile start time to now + Planet.profileStartTime = time.time() Planet = InnerLayers(Planet, Params) Planet = ElecConduct(Planet, Params) Planet = SeismicCalcs(Planet, Params) @@ -340,13 +346,13 @@ def InteriorEtc(Planet, Params): WriteSeismic(Planet, Params) # Create a simplified reduced planet structure for magnetic induction and/or gravity calculations - if Planet.Do.VALID: - Planet, Params = GetReducedPlanetProfile(Planet, Params) - if not Params.SKIP_INDUCTION and (Params.CALC_CONDUCT and Params.CALC_NEW_INDUCT): + if (Planet.Do.VALID or (Params.ALLOW_BROKEN_MODELS and Planet.Do.STILL_CALCULATE_BROKEN_PROPERTIES)) and (not Params.SKIP_INDUCTION or not Params.SKIP_GRAVITY): + Planet, Params = GetReducedPlanet(Planet, Params) + if (Params.CALC_CONDUCT and (Planet.Do.VALID or (Params.ALLOW_BROKEN_MODELS and Planet.Do.STILL_CALCULATE_BROKEN_PROPERTIES))) and not Params.SKIP_INDUCTION: # Calculate induced magnetic moments Planet, Params = MagneticInduction(Planet, Params) # Gravity calcuations and plots - if (Params.CALC_SEISMIC and Params.CALC_VISCOSITY) and not Params.SKIP_GRAVITY: + if (Params.CALC_SEISMIC and Params.CALC_VISCOSITY) and (Planet.Do.VALID or (Params.ALLOW_BROKEN_MODELS and Planet.Do.STILL_CALCULATE_BROKEN_PROPERTIES)) and not Params.SKIP_GRAVITY: # Calculate gravity parameters Planet, Params = GravityParameters(Planet, Params) PrintCompletion(Planet, Params) @@ -356,8 +362,10 @@ def InteriorEtc(Planet, Params): def InductionOnly(Planet, Params): """ Wrapper for MagneticInduction function similar to above that includes PrintCompletion. """ - - Planet, Params = MagneticInduction(Planet, Params) + # Reset profile start time to now + Planet.profileStartTime = time.time() + if (Params.CALC_CONDUCT and (Planet.Do.VALID or (Params.ALLOW_BROKEN_MODELS and Planet.Do.STILL_CALCULATE_BROKEN_PROPERTIES))) and not Params.SKIP_INDUCTION: + Planet, Params = MagneticInduction(Planet, Params) PrintCompletion(Planet, Params) return Planet, Params @@ -376,6 +384,11 @@ def PrintCompletion(Planet, Params): ending = '!' else: tNow_s = time.time() + if Planet.profileStartTime is not None: + tProfile_s = tNow_s - Planet.profileStartTime + tProfileString = f'Profile took {tProfile_s:.3f} s' + else: + tProfileString = '' tTot_s = Params.nModels / Planet.index * (tNow_s - Params.tStart_s) tRemain_s = (Params.tStart_s + tTot_s - tNow_s) remain = '' @@ -389,7 +402,7 @@ def PrintCompletion(Planet, Params): remain += f' {int(tRemain_s/60)} min' remain += f' {int(tRemain_s % 60)} s' - ending = f'. Approx.{remain} remaining.' + ending = f'. {tProfileString}. Approx.{remain} remaining.' log.profile(f'Profile{indicator} complete{ending}') return @@ -740,17 +753,27 @@ def ReloadProfile(Planet, Params, fnameOverride=None): if Planet.Do.NO_H2O or Planet.Do.NO_DIFFERENTIATION or Planet.Do.PARTIAL_DIFFERENTIATION: Planet.Do.NO_OCEAN = True if not Planet.Do.NO_OCEAN: - with open(Params.DataFiles.oceanPropsFile) as f: - nHeadLines = int(f.readline().split('=')[-1]) - Planet.Ocean.aqueousSpecies = np.array(f.readline().split('=')[-1].strip().replace(',', '').split()) - Planet.Ocean.reaction = f.readline().split('=')[-1].strip() - Planet.Ocean.reactionDisequilibriumConcentrations = f.readline().split('=')[-1].strip() - OceanSpecificProps = np.loadtxt(Params.DataFiles.oceanPropsFile, skiprows=nHeadLines, unpack=True) - Planet.Ocean.Bulk_pHs = OceanSpecificProps[2] - Planet.Ocean.affinity_kJ = OceanSpecificProps[3] - Planet.Ocean.aqueousSpeciesAmount_mol = OceanSpecificProps[4: ] + if Params.CALC_OCEAN_PROPS: + with open(Params.DataFiles.oceanPropsFile) as f: + nHeadLines = int(f.readline().split('=')[-1]) + Planet.Ocean.aqueousSpecies = np.array(f.readline().split('=')[-1].strip().replace(';', '').split()) + Planet.Ocean.Reaction.reaction = f.readline().split('=')[-1].strip() + Planet.Ocean.Reaction.useReferenceSpecies = bool(strtobool(f.readline().split('=')[-1].strip())) + Planet.Ocean.Reaction.referenceSpecies = f.readline().split('=')[-1].strip() + Planet.Ocean.Reaction.disequilibriumConcentrations = ast.literal_eval(f.readline().split('=')[-1].strip()) + OceanSpecificProps = np.loadtxt(Params.DataFiles.oceanPropsFile, skiprows=nHeadLines, unpack=True) + Planet.Ocean.Bulk_pHs = OceanSpecificProps[2] + Planet.Ocean.affinity_kJ = OceanSpecificProps[3] + Planet.Ocean.aqueousSpeciesAmount_mol = OceanSpecificProps[4: ] + Planet.Ocean.pHSeafloor = Planet.Ocean.Bulk_pHs[-1] + Planet.Ocean.Mean_pH = np.mean(Planet.Ocean.Bulk_pHs) + Planet.Ocean.affinitySeafloor_kJ = Planet.Ocean.affinity_kJ[-1] + Planet.Ocean.affinityMean_kJ = np.mean(Planet.Ocean.affinity_kJ) + else: + Planet.Ocean.Reaction.reaction, Planet.Ocean.Reaction.disequilibriumConcentrations = 'NaN', 'NaN' + Planet.Ocean.Bulk_pHs, Planet.Ocean.affinity_kJ, Planet.Ocean.Reacton_pHs, Planet.Ocean.aqueousSpeciesAmount_mol, Planet.Ocean.aqueousSpecies = np.nan, np.nan, np.nan, np.nan, np.nan else: - Planet.Ocean.reaction, Planet.Ocean.reactionDisequilibriumConcentrations = 'NaN', 'NaN' + Planet.Ocean.Reaction.reaction, Planet.Ocean.Reaction.disequilibriumConcentrations = 'NaN', 'NaN' Planet.Ocean.Bulk_pHs, Planet.Ocean.affinity_kJ, Planet.Ocean.Reacton_pHs, Planet.Ocean.aqueousSpeciesAmount_mol, Planet.Ocean.aqueousSpecies = np.nan, np.nan, np.nan, np.nan, np.nan # Setup CustomSolution settings @@ -838,26 +861,15 @@ def InductOgram(bodyname, Params, fNameOverride=None): Planet = importlib.import_module(expected[:-3].replace(os.sep, '.')).Planet Planet, Params.DataFiles, Params.FigureFiles = SetupFilenames(Planet, Params) - Induction = InductionStruct() + InductionResults = InductionResultsStruct() else: # Re-use interior modeling, but recalculate the induced field. log.info(f'Reloading induct-o-gram type {Params.Induct.inductOtype} to recalculate induction. ' + 'Axis settings in configPPinduct will be ignored.') - Induction, Planet, Params = ReloadInductOgram(bodyname, Params, fNameOverride=fNameOverride) - - if Params.CALC_ASYM: - # If we are doing asymmetry, limit to low-degree moments to compute in a reasonable time - Planet.Magnetic.pMax = 2 - else: - Planet.Magnetic.pMax = 0 - Planet.Magnetic.Texc_hr, Planet.Magnetic.omegaExc_radps, Planet.Magnetic.Benm_nT, \ - Planet.Magnetic.B0_nT, _ \ - = GetBexc(Planet.name, Planet.Magnetic.SCera, Planet.Magnetic.extModel, - Params.Induct.excSelectionCalc, nprmMax=Planet.Magnetic.nprmMax, - pMax=Planet.Magnetic.pMax) - Planet.Magnetic.nExc = np.size(Planet.Magnetic.Texc_hr) - Benm_nT = Planet.Magnetic.Benm_nT + InductionResults, Planet, Params = ReloadInductOgram(bodyname, Params, fNameOverride=fNameOverride) + + Planet, Params = SetupInduction(Planet, Params) tMarks = np.empty(0) tMarks = np.append(tMarks, time.time()) @@ -867,17 +879,19 @@ def InductOgram(bodyname, Params, fNameOverride=None): sigmaList = np.logspace(Params.Induct.sigmaMin[bodyname], Params.Induct.sigmaMax[bodyname], Params.Induct.nSigmaPts) Dlist = np.logspace(Params.Induct.Dmin[bodyname], Params.Induct.Dmax[bodyname], Params.Induct.nDpts) else: - sigmaList = Induction.sigmaMean_Sm[:,0] - Dlist = Induction.D_km[0,:] + sigmaList = InductionResults.sigmaMean_Sm[:,0] + Dlist = InductionResults.D_km[0,:] Params.Induct.sigmaMin[bodyname] = np.min(sigmaList) Params.Induct.sigmaMax[bodyname] = np.max(sigmaList) Params.Induct.nSigmaPts = np.size(sigmaList) Params.Induct.Dmin[bodyname] = np.min(Dlist) Params.Induct.Dmax[bodyname] = np.max(Dlist) Params.Induct.nDpts = np.size(Dlist) - Params.Induct.zbFixed_km[bodyname] = Induction.zb_km[0,0] + Params.Induct.zbFixed_km[bodyname] = InductionResults.zb_km[0,0] Params.nModels = Params.Induct.nSigmaPts * Params.Induct.nDpts + InductionResults.xData = sigmaList + InductionResults.yData = Dlist Planet.zb_km = Params.Induct.zbFixed_km[bodyname] Planet.Magnetic.rSigChange_m = np.array([0, Planet.Bulk.R_m - Planet.zb_km * 1e3, Planet.Bulk.R_m]) Planet.Magnetic.sigmaLayers_Sm = np.array([Planet.Ocean.sigmaIce_Sm['Ih'], 0, Planet.Sil.sigmaSil_Sm]) @@ -901,11 +915,13 @@ def InductOgram(bodyname, Params, fNameOverride=None): oceanCompList = loadmat(Params.DataFiles.xRangeData)['Data'].flatten().tolist() zbApproximateList = np.linspace(Params.Induct.zbMin[bodyname], Params.Induct.zbMax[bodyname], Params.Induct.nZbPts) else: - zbApproximateList = Induction.zb_approximate_km[0,:] - oceanCompList = Induction.oceanComp[:,0] + zbApproximateList = InductionResults.zb_approximate_km[0,:] + oceanCompList = InductionResults.oceanComp[:,0] Params.Induct.nOceanCompPts = np.size(oceanCompList) - + + InductionResults.xData = oceanCompList + InductionResults.yData = zbApproximateList Params.nModels = Params.Induct.nZbPts * Params.Induct.nOceanCompPts PlanetGrid = np.empty((Params.Induct.nOceanCompPts, Params.Induct.nZbPts), dtype=object) @@ -921,8 +937,8 @@ def InductOgram(bodyname, Params, fNameOverride=None): else: for i, oceanComp in enumerate(oceanCompList): for j, zbApproximate_km in enumerate(zbApproximateList): - Planet.Magnetic.rSigChange_m = Induction.rBds_m[i,j] - Planet.Magnetic.sigmaLayers_Sm = Induction.sigmaLayers_Sm[i,j] + Planet.Magnetic.rSigChange_m = InductionResults.rBds_m[i,j] + Planet.Magnetic.sigmaLayers_Sm = InductionResults.sigmaLayers_Sm[i,j] if np.any(np.isnan(Planet.Magnetic.sigmaLayers_Sm)): Planet.Do.VALID = False Planet.invalidReason = 'Some conductivity layers invalid' @@ -933,7 +949,7 @@ def InductOgram(bodyname, Params, fNameOverride=None): if Params.CALC_NEW: wList = np.logspace(Params.Induct.wMin[bodyname], Params.Induct.wMax[bodyname], Params.Induct.nwPts) else: - wList = Induction.wOcean_ppt[:,0] + wList = InductionResults.wOcean_ppt[:,0] Params.Induct.wMin[bodyname] = np.min(wList) Params.Induct.wMax[bodyname] = np.max(wList) Params.Induct.nwPts = np.size(wList) @@ -942,11 +958,13 @@ def InductOgram(bodyname, Params, fNameOverride=None): if Params.CALC_NEW: TbList = np.linspace(Params.Induct.TbMin[bodyname], Params.Induct.TbMax[bodyname], Params.Induct.nTbPts) else: - TbList = Induction.Tb_K[0,:] + TbList = InductionResults.Tb_K[0,:] Params.Induct.TbMin[bodyname] = np.min(TbList) Params.Induct.TbMax[bodyname] = np.max(TbList) Params.Induct.nTbPts = np.size(TbList) + InductionResults.xData = wList + InductionResults.yData = TbList Params.nModels = Params.Induct.nwPts * Params.Induct.nTbPts PlanetGrid = np.empty((Params.Induct.nwPts, Params.Induct.nTbPts), dtype=object) @@ -961,8 +979,8 @@ def InductOgram(bodyname, Params, fNameOverride=None): else: for i, w_ppt in enumerate(wList): for j, Tb_K in enumerate(TbList): - Planet.Magnetic.rSigChange_m = Induction.rBds_m[i,j] - Planet.Magnetic.sigmaLayers_Sm = Induction.sigmaLayers_Sm[i,j] + Planet.Magnetic.rSigChange_m = InductionResults.rBds_m[i,j] + Planet.Magnetic.sigmaLayers_Sm = InductionResults.sigmaLayers_Sm[i,j] if np.any(np.isnan(Planet.Magnetic.sigmaLayers_Sm)): Planet.Do.VALID = False Planet.invalidReason = 'Some conductivity layers invalid' @@ -977,11 +995,13 @@ def InductOgram(bodyname, Params, fNameOverride=None): if Params.CALC_NEW: phiList = np.logspace(Params.Induct.phiMin[bodyname], Params.Induct.phiMax[bodyname], Params.Induct.nphiPts) else: - phiList = Induction.phiRockMax_frac[0,:] + phiList = InductionResults.phiRockMax_frac[0,:] Params.Induct.phiMin[bodyname] = np.min(phiList) Params.Induct.phiMax[bodyname] = np.max(phiList) Params.Induct.nphiPts = np.size(phiList) + InductionResults.xData = wList + InductionResults.yData = phiList Params.nModels = Params.Induct.nwPts * Params.Induct.nphiPts PlanetGrid = np.empty((Params.Induct.nwPts, Params.Induct.nphiPts), dtype=object) @@ -996,8 +1016,8 @@ def InductOgram(bodyname, Params, fNameOverride=None): else: for i, w_ppt in enumerate(wList): for j, phiRockMax_frac in enumerate(phiList): - Planet.Magnetic.rSigChange_m = Induction.rBds_m[i,j] - Planet.Magnetic.sigmaLayers_Sm = Induction.sigmaLayers_Sm[i,j] + Planet.Magnetic.rSigChange_m = InductionResults.rBds_m[i,j] + Planet.Magnetic.sigmaLayers_Sm = InductionResults.sigmaLayers_Sm[i,j] if np.any(np.isnan(Planet.Magnetic.sigmaLayers_Sm)): Planet.Do.VALID = False Planet.invalidReason = 'Some conductivity layers invalid' @@ -1011,11 +1031,13 @@ def InductOgram(bodyname, Params, fNameOverride=None): if Params.CALC_NEW: rhoList = np.linspace(Params.Induct.rhoMin[bodyname], Params.Induct.rhoMax[bodyname], Params.Induct.nrhoPts) else: - rhoList = Induction.rhoSilMean_kgm3[0,:] + rhoList = InductionResults.rhoSilMean_kgm3[0,:] Params.Induct.rhoMin[bodyname] = np.min(rhoList) Params.Induct.rhoMax[bodyname] = np.max(rhoList) Params.Induct.nrhoPts = np.size(rhoList) + InductionResults.xData = wList + InductionResults.yData = rhoList Params.nModels = Params.Induct.nwPts * Params.Induct.nrhoPts PlanetGrid = np.empty((Params.Induct.nwPts, Params.Induct.nrhoPts), dtype=object) @@ -1030,8 +1052,8 @@ def InductOgram(bodyname, Params, fNameOverride=None): else: for i, w_ppt in enumerate(wList): for j, rhoSilWithCore_kgm3 in enumerate(rhoList): - Planet.Magnetic.rSigChange_m = Induction.rBds_m[i,j] - Planet.Magnetic.sigmaLayers_Sm = Induction.sigmaLayers_Sm[i,j] + Planet.Magnetic.rSigChange_m = InductionResults.rBds_m[i,j] + Planet.Magnetic.sigmaLayers_Sm = InductionResults.sigmaLayers_Sm[i,j] if np.any(np.isnan(Planet.Magnetic.sigmaLayers_Sm)): Planet.Do.VALID = False Planet.invalidReason = 'Some conductivity layers invalid' @@ -1046,158 +1068,53 @@ def InductOgram(bodyname, Params, fNameOverride=None): log.info('PlanetGrid constructed. Calculating induction responses.') Params.INDUCTOGRAM_IN_PROGRESS = True PlanetGrid = ParPlanet(PlanetGrid, Params) + Params.INDUCTOGRAM_IN_PROGRESS = False tMarks = np.append(tMarks, time.time()) dt = tMarks[-1] - tMarks[-2] log.info(f'Parallel run elapsed time: {dt:.1f} s.') - - # Organize data into a format that can be plotted/saved for plotting - Bex_nT, Bey_nT, Bez_nT = Benm2absBexyz(Benm_nT) - nPeaks = np.size(Bex_nT) - - if Params.CALC_NEW: - Induction.bodyname = bodyname - Induction.yName = Params.Induct.inductOtype - Induction.wOcean_ppt = np.array([[Planeti.Ocean.wOcean_ppt for Planeti in line] for line in PlanetGrid]) - Induction.oceanComp = np.array([[Planeti.Ocean.comp for Planeti in line] for line in PlanetGrid]) - Induction.oceanComp = np.char.rstrip(Induction.oceanComp) - Induction.zb_approximate_km = np.array([[Planeti.Bulk.zb_approximate_km for Planeti in line] for line in PlanetGrid]) - Induction.Tb_K = np.array([[Planeti.Bulk.Tb_K for Planeti in line] for line in PlanetGrid]) - Induction.Tmean_K = np.array([[Planeti.Ocean.Tmean_K if Planeti.Ocean.Tmean_K is not None else np.nan for Planeti in line] for line in PlanetGrid]) - Induction.rhoSilMean_kgm3 = np.array([[Planeti.Sil.rhoMean_kgm3 if Planeti.Sil.rhoMean_kgm3 is not None else np.nan for Planeti in line] for line in PlanetGrid]) - Induction.phiRockMax_frac = np.array([[Planeti.Sil.phiRockMax_frac if Planeti.Sil.phiRockMax_frac is not None else np.nan for Planeti in line] for line in PlanetGrid]) - Induction.sigmaMean_Sm = np.array([[Planeti.Ocean.sigmaMean_Sm if Planeti.Ocean.sigmaMean_Sm is not None else np.nan for Planeti in line] for line in PlanetGrid]) - Induction.sigmaTop_Sm = np.array([[Planeti.Ocean.sigmaTop_Sm if Planeti.Ocean.sigmaTop_Sm is not None else np.nan for Planeti in line] for line in PlanetGrid]) - Induction.D_km = np.array([[Planeti.D_km if Planeti.D_km is not None else np.nan for Planeti in line] for line in PlanetGrid]) - Induction.zb_km = np.array([[Planeti.zb_km if Planeti.zb_km is not None else np.nan for Planeti in line] for line in PlanetGrid]) - Induction.R_m = np.array([[Planeti.Bulk.R_m for Planeti in line] for line in PlanetGrid]) - nLayers = next(np.size(Planet.Magnetic.rSigChange_m) for Planet in PlanetGrid.flatten() if Planet.Magnetic.rSigChange_m is not None) - nanLayers = np.nan*np.empty(nLayers) - # Note: Induction.rBds_m and Induction.sigmaLayers_Sm can have different lengths for each entry in PlanetGrid. - # To make an array under this condition, the lists for these entries must be objects (tuples here). - Induction.rBds_m = np.array([[Planeti.Magnetic.rSigChange_m if Planeti.Magnetic.rSigChange_m is not None else nanLayers for Planeti in line] for line in PlanetGrid], dtype=np.float64) - Induction.sigmaLayers_Sm = np.array([[Planeti.Magnetic.sigmaLayers_Sm if Planeti.Magnetic.sigmaLayers_Sm is not None else nanLayers for Planeti in line] for line in PlanetGrid], dtype=np.float64) - - Induction.Texc_hr = Mag.Texc_hr[bodyname] - Induction.Amp = np.array([[[Planeti.Magnetic.Amp[i] if Planeti.Magnetic.Amp is not None else np.nan for Planeti in line] for line in PlanetGrid] for i in range(nPeaks)]) - Induction.phase = np.array([[[Planeti.Magnetic.phase[i] if Planeti.Magnetic.phase is not None else np.nan for Planeti in line] for line in PlanetGrid] for i in range(nPeaks)]) - Induction.Bix_nT = np.array([Induction.Amp[i, ...] * Bex_nT[i] for i in range(nPeaks)]) - Induction.Biy_nT = np.array([Induction.Amp[i, ...] * Bey_nT[i] for i in range(nPeaks)]) - Induction.Biz_nT = np.array([Induction.Amp[i, ...] * Bez_nT[i] for i in range(nPeaks)]) - - WriteInductOgram(Induction, Params) - Induction.SetAxes(Params.Induct.inductOtype) - Induction.SetComps(Params.Induct.inductOtype) + + InductionResults = ExtractResults(InductionResults, PlanetGrid, Params) + InductionResults.oceanComp = InductionResults.base.oceanComp + WriteResults(InductionResults, Params.DataFiles.inductOgramFile, Params.SAVE_AS_MATLAB, Params.DataFiles.inductOgramMatFile) + InductionResults.SetAxes(Params.Induct.inductOtype) + InductionResults.SetComps(Params.Induct.inductOtype) else: log.info(f'Reloading induct-o-gram type {Params.Induct.inductOtype}.') - Induction, _, Params = ReloadInductOgram(bodyname, Params, fNameOverride=fNameOverride) - - return Induction, Params - - -def WriteInductOgram(Induction, Params): - """ Organize Induction results from an induct-o-gram run into a dict - and print to a .mat file. - """ - - saveDict = { - 'bodyname': Induction.bodyname, - 'yName': Induction.yName, - 'Texc_hr_keys': [key for key in Induction.Texc_hr.keys()], - 'Texc_hr_values': [value if value is not None else np.nan for value in Induction.Texc_hr.values()], - 'Amp': Induction.Amp, - 'phase': Induction.phase, - 'Bix_nT': Induction.Bix_nT, - 'Biy_nT': Induction.Biy_nT, - 'Biz_nT': Induction.Biz_nT, - 'w_ppt': Induction.wOcean_ppt, - 'oceanComp': Induction.oceanComp, - 'Tb_K': Induction.Tb_K, - 'Tmean_K': Induction.Tmean_K, - 'rhoSilMean_kgm3': Induction.rhoSilMean_kgm3, - 'phiRockMax_frac': Induction.phiRockMax_frac, - 'sigmaMean_Sm': Induction.sigmaMean_Sm, - 'sigmaTop_Sm': Induction.sigmaTop_Sm, - 'D_km': Induction.D_km, - 'zb_km': Induction.zb_km, - 'R_m': Induction.R_m, - 'rBds_m': Induction.rBds_m, - 'sigmaLayers_Sm': Induction.sigmaLayers_Sm, - 'zb_approximate_km': Induction.zb_approximate_km, - } - savemat(Params.DataFiles.inductOgramFile, saveDict) - log.info(f'Saved induct-o-gram {Params.DataFiles.inductOgramFile} to disk.') + InductionResults, _, Params = ReloadInductOgram(bodyname, Params, fNameOverride=fNameOverride) - return + return InductionResults, Params def ReloadInductOgram(bodyname, Params, fNameOverride=None): """ Reload a previously run induct-o-gram from disk. """ - - if bodyname[:4] == 'Test': - loadname = bodyname + '' - bodydir = _TestImport - else: - loadname = bodyname - bodydir = bodyname - Planet = importlib.import_module(f'{bodydir}.PP{loadname}InductOgram').Planet - Planet, Params.DataFiles, Params.FigureFiles = SetupFilenames(Planet, Params) - if fNameOverride is None: - loadFile = Params.DataFiles.inductOgramFile - else: - if '.mat' in fNameOverride: - reload = loadmat(fNameOverride) + if bodyname[:4] == 'Test': + loadname = bodyname + '' + bodydir = _TestImport else: + loadname = bodyname bodydir = bodyname - Planet = importlib.import_module(f'{bodydir}.{fNameOverride[:-3]}').Planet - loadFile = fNameOverride - # Common setup for fnames that are not .mat already - if not (fNameOverride and '.mat' in fNameOverride): - Planet, Params.DataFiles, Params.FigureFiles = SetupFilenames(Planet, Params) - loadFile = Params.DataFiles.inductOgramFile - - - if os.path.isfile(loadFile): - reload = loadmat(loadFile) - + planetModuleName = f'{bodydir}.PP{loadname}InductOgram' + else: + planetModuleName = f'{bodyname}.{fNameOverride[:-3]}' + + Planet = importlib.import_module(planetModuleName).Planet + Planet, Params.DataFiles, Params.FigureFiles = SetupFilenames(Planet, Params) + + if os.path.isfile(Params.DataFiles.inductOgramFile): + InductionResults = ReloadResultsFromPickle(Params.DataFiles.inductOgramFile) else: - raise FileNotFoundError(f'Attempted to reload inductogram, but {loadFile} was not found. ' + + raise FileNotFoundError(f'Attempted to reload inductogram, but {Params.DataFiles.inductOgramFile} was not found. ' + 'Re-run with Params.CALC_NEW_INDUCT = True in configPP.py.') - Induction = InductionStruct() - Induction.bodyname = reload['bodyname'][0] - Induction.yName = reload['yName'][0] - Induction.Texc_hr = {key.strip(): value if np.isfinite(value) else None for key, value in zip(reload['Texc_hr_keys'], reload['Texc_hr_values'][0])} - Induction.Amp = reload['Amp'] - Induction.phase = reload['phase'] - Induction.Bix_nT = reload['Bix_nT'] - Induction.Biy_nT = reload['Biy_nT'] - Induction.Biz_nT = reload['Biz_nT'] - Induction.wOcean_ppt = reload['w_ppt'] - Induction.oceanComp = reload['oceanComp'] - Induction.oceanComp = np.char.rstrip(Induction.oceanComp) - Induction.Tb_K = reload['Tb_K'] - Induction.Tmean_K = reload['Tmean_K'] - Induction.rhoSilMean_kgm3 = reload['rhoSilMean_kgm3'] - Induction.phiRockMax_frac = reload['phiRockMax_frac'] - Induction.sigmaMean_Sm = reload['sigmaMean_Sm'] - Induction.sigmaTop_Sm = reload['sigmaTop_Sm'] - Induction.D_km = reload['D_km'] - Induction.zb_km = reload['zb_km'] - Induction.R_m = reload['R_m'] - Induction.rBds_m = reload['rBds_m'] - Induction.sigmaLayers_Sm = reload['sigmaLayers_Sm'] - Induction.zb_approximate_km = reload['zb_approximate_km'] - Induction.oceanComp = reload['oceanComp'] - - Induction.SetAxes(Params.Induct.inductOtype) - Induction.SetComps(Params.Induct.inductOtype) - - Params = SetupCustomSolutionPlotSettings(Induction.oceanComp, Params) - - return Induction, Planet, Params + InductionResults.SetAxes(Params.Induct.inductOtype) + InductionResults.SetComps(Params.Induct.inductOtype) + + Params = SetupCustomSolutionPlotSettings(InductionResults.base.oceanComp, Params) + + return InductionResults, Planet, Params def ParPlanet(PlanetList, Params): @@ -1520,6 +1437,9 @@ def GridPlanetProfileFunc(FuncName, PlanetGrid, Params): funcName function. """ PlanetList1D = np.reshape(PlanetGrid, -1) + if Params.PRELOAD_EOS: + log.info('Preloading EOS tables. This may take some time.') + PrecomputeEOS(PlanetList1D, Params) if Params.DO_PARALLEL: # Prevent slowdowns from competing process spawning when #cores > #jobs nCores = np.min([Params.maxCores, np.prod(np.shape(PlanetList1D)), Params.threadLimit]) @@ -1539,6 +1459,190 @@ def GridPlanetProfileFunc(FuncName, PlanetGrid, Params): return PlanetGrid +def MonteCarlo(bodyname, Params, fNameOverride=None): + """ Run PlanetProfile models with Monte Carlo parameter sampling to explore + parameter space and assess uncertainty. + """ + if Params.CALC_NEW: + log.info(f'Running Monte Carlo exploration for {bodyname} with {Params.MonteCarlo.nRuns} samples.') + + if bodyname[:4] == 'Test': + loadname = bodyname + '' + bodyname = 'Test' + bodydir = os.path.join('PlanetProfile', 'Test') + else: + loadname = bodyname + bodydir = bodyname + if fNameOverride is not None: + fName = fNameOverride + else: + fName = f'PP{loadname}.py' + expected = os.path.join(bodydir, fName) + if not os.path.isfile(expected): + default = os.path.join(_Defaults, bodydir, fName) + if os.path.isfile(default): + CopyCarefully(default, expected) + else: + log.warning(f'{expected} does not exist and no default was found at {default}.') + Planet = importlib.import_module(expected[:-3].replace(os.sep, '.')).Planet + tMarks = np.empty(0) + tMarks = np.append(tMarks, time.time()) + + # Initialize Monte Carlo results structure + MCResults = MonteCarloResultsStruct() + MCResults.bodyname = bodyname + MCResults.statistics.nRuns = Params.MonteCarlo.nRuns + MCResults.statistics.seed = Params.MonteCarlo.seed + + # Set random seed if provided + if Params.MonteCarlo.seed is not None: + np.random.seed(Params.MonteCarlo.seed) + + # Determine which parameters to search over + if Planet.Do.NON_SELF_CONSISTENT: + MCResults.statistics.paramsToSearch = Params.MonteCarlo.paramsToSearchNonSelfConsistent + else: + MCResults.statistics.paramsToSearch = Params.MonteCarlo.paramsToSearchSelfConsistent + + MCResults.statistics.paramsUsed = MCResults.statistics.paramsToSearch.copy() + MCResults.statistics.paramsRanges = {param: Params.MonteCarlo.paramsRanges[param] for param in MCResults.statistics.paramsToSearch} + MCResults.statistics.paramsDistributions = {param: Params.MonteCarlo.paramsDistributions[param] for param in MCResults.statistics.paramsToSearch} + + # Configure parameters for Monte Carlo runs + Params.nModels = MCResults.statistics.nRuns + Params.ALLOW_BROKEN_MODELS = True + Params.NO_SAVEFILE = True + if Params.CALC_NEW_GRAVITY: + log.warning(f'Params.CALC_NEW_GRAVITY is True. Will enable seismic and viscosity calculations.') + Params.CALC_VISCOSITY = True + if Params.CALC_NEW_INDUCT: + log.warning(f'Params.CALC_INDUCT is True. Will enable conductivity calculations and pre-load induction.') + Params.CALC_CONDUCT = True + Planet, Params = SetupInduction(Planet, Params) + else: + MCResults.induction.excSelectionCalc = {} + MCResults.induction.nPeaks = 0 + + # Generate parameter samples + log.info(f'Generating {MCResults.statistics.nRuns} parameter samples...') + PlanetList = SampleMonteCarloParameters(MCResults, Planet, Params) + + # Set up filenames + Planet, DataFiles, FigureFiles = SetupFilenames(Planet, Params, monteCarloAppend='MonteCarlo', figExploreAppend=Params.Explore.zName) + Params.DataFiles = DataFiles + Params.FigureFiles = FigureFiles + + + # Set log level + if Params.DO_PARALLEL: + if Params.logParallel > logging.INFO: + log.info('Quieting messages to avoid spam in gridded run.') + saveLevel = log.getEffectiveLevel() + 0 + log.setLevel(Params.logParallel) + else: + saveLevel = log.getEffectiveLevel() + 0 + + # Set flag to indicate that we are running Monte Carlo + Params.MONTECARLO_IN_PROGRESS = True + + # Run Monte Carlo models in parallel + log.info(f'Running {MCResults.statistics.nRuns} Monte Carlo models...') + tMarks = np.append(tMarks, time.time()) + PlanetList = GridPlanetProfileFunc(PlanetProfile, np.array(PlanetList), Params) + tMarks = np.append(tMarks, time.time()) + dt = tMarks[-1] - tMarks[-2] + MCResults.statistics.totalTime_s = dt + MCResults.statistics.avgTime_s = dt / MCResults.statistics.nRuns + log.info(f'Monte Carlo run elapsed time: {dt:.1f} s.') + # Reset log level + log.setLevel(saveLevel) + # Reset flag to indicate that we are not running Monte Carlo + Params.MONTECARLO_IN_PROGRESS = False + + # Exploreogram results + MCResults = ExtractResults(MCResults, PlanetList, Params) + # Save results + WriteResults(MCResults, Params.DataFiles.montecarloFile, Params.SAVE_AS_MATLAB, Params.DataFiles.montecarloMatFile) + + + else: + log.info(f'Reloading Monte Carlo results for {bodyname}.') + MCResults, Params = ReloadMonteCarloResults(bodyname, Params, fNameOverride=fNameOverride) + + return MCResults, Params + + +def SampleMonteCarloParameters(MCResults, basePlanet, Params): + """ Generate Monte Carlo parameter samples and create PlanetList with sampled parameters. + """ + PlanetList = [] + MCResults.statistics.paramValues = {} + + # Initialize parameter value storage + for param in MCResults.statistics.paramsToSearch: + if param == 'oceanComp': + MCResults.statistics.paramValues[param] = np.empty(MCResults.statistics.nRuns, dtype=object) + else: + MCResults.statistics.paramValues[param] = np.empty(MCResults.statistics.nRuns) + + # Set index marker + k = 1 + for i in range(MCResults.statistics.nRuns): + planet = deepcopy(basePlanet) + planet.index = k + + # Sample and assign parameters + for param in MCResults.statistics.paramsToSearch: + paramRange = MCResults.statistics.paramsRanges[param] + distribution = MCResults.statistics.paramsDistributions[param] + + if distribution == 'uniform': + val = np.random.uniform(paramRange[0], paramRange[1]) + elif distribution == 'discrete': + paramSize = len(paramRange) + index = np.random.randint(0, paramSize) + val = paramRange[index] + else: + raise ValueError(f'Distribution type {distribution} not implemented.') + + # Store sampled value + MCResults.statistics.paramValues[param][i] = val + + # Assign value to planet + planet = AssignPlanetVal(planet, param, val) + + PlanetList.append(planet) + + # Update index + k += 1 + + return PlanetList + + +def ReloadMonteCarloResults(bodyname, Params, fNameOverride=None): + """ Reload previously saved Monte Carlo results. + """ + if fNameOverride is None: + if bodyname[:4] == 'Test': + loadname = bodyname + '' + bodydir = _TestImport + else: + loadname = bodyname + bodydir = bodyname + Planet = importlib.import_module(f'{bodydir}.PP{loadname}').Planet + Planet, Params.DataFiles, Params.FigureFiles = SetupFilenames(Planet, Params, monteCarloAppend='MonteCarlo', figExploreAppend=Params.Explore.zName) + fName = Params.DataFiles.montecarloFile + else: + bodydir = bodyname + Planet = importlib.import_module(f'{bodydir}.{fNameOverride[:-3]}').Planet + Planet, Params.DataFiles, Params.FigureFiles = SetupFilenames(Planet, Params, monteCarloAppend='MonteCarlo', figExploreAppend=Params.Explore.zName) + fName = Params.DataFiles.montecarloFile + + MCResults = ReloadResultsFromPickle(fName) + + return MCResults, Params + + def ExploreOgram(bodyname, Params, fNameOverride=None, RETURN_GRID=False, Magnetic=None): """ Run PlanetProfile models over a variety of settings to get interior properties for each input. @@ -1570,16 +1674,18 @@ def ExploreOgram(bodyname, Params, fNameOverride=None, RETURN_GRID=False, Magnet if Magnetic is not None: Planet.Magnetic = Magnetic + else: + if Params.CALC_NEW_INDUCT: + Params.CALC_CONDUCT = True + Planet, Params = SetupInduction(Planet, Params) - Exploration = ExplorationStruct() + + Exploration = ExplorationResultsStruct() Exploration.xName = Params.Explore.xName Exploration.yName = Params.Explore.yName Exploration.zName = Params.Explore.zName Planet, DataFiles, FigureFiles = SetupFilenames(Planet, Params, exploreAppend=f'{Exploration.xName}{Params.Explore.xRange[0]}_{Params.Explore.xRange[1]}_{Exploration.yName}{Params.Explore.yRange[0]}_{Params.Explore.yRange[1]}', figExploreAppend=Params.Explore.zName) - if bodyname == 'Test': - Params.Explore.nx = 5 - Params.Explore.ny = 5 if Params.Explore.xName in Params.Explore.provideExploreRange: xList = loadmat(DataFiles.xRangeData)['Data'].flatten().tolist() @@ -1587,16 +1693,24 @@ def ExploreOgram(bodyname, Params, fNameOverride=None, RETURN_GRID=False, Magnet if Params.Explore.nx != len(xList): raise ValueError(f"Size of provided range list ({len(xList)}) does not match input Params.Explore.nx {Params.Explore.nx}. Adjust so they match.") else: - xList = np.linspace(Params.Explore.xRange[0], Params.Explore.xRange[1], Params.Explore.nx) + if Exploration.xName in Params.Explore.exploreLogScale: + xList = np.logspace(Params.Explore.xRange[0], Params.Explore.xRange[1], Params.Explore.nx) + else: + xList = np.linspace(Params.Explore.xRange[0], Params.Explore.xRange[1], Params.Explore.nx) if Params.Explore.yName in Params.Explore.provideExploreRange: yList = loadmat(DataFiles.yRangeData)['Data'].flatten().tolist() yList = [s.strip() if isinstance(s, str) else s for s in yList] if Params.Explore.ny != len(yList): raise ValueError(f"Size of provided range list ({len(yList)}) does not match input Params.Explore.nx {Params.Explore.ny}. Adjust so they match.") else: - yList = np.linspace(Params.Explore.yRange[0], Params.Explore.yRange[1], Params.Explore.ny) + if Exploration.yName in Params.Explore.exploreLogScale: + yList = np.logspace(Params.Explore.yRange[0], Params.Explore.yRange[1], Params.Explore.ny) + else: + yList = np.linspace(Params.Explore.yRange[0], Params.Explore.yRange[1], Params.Explore.ny) Params.nModels = Params.Explore.nx * Params.Explore.ny + Exploration.xData = xList + Exploration.yData = yList if not Params.SKIP_INNER: log.warning('Running explore-o-gram with interior calculations, which will be slow.') @@ -1606,7 +1720,9 @@ def ExploreOgram(bodyname, Params, fNameOverride=None, RETURN_GRID=False, Magnet Params.ALLOW_BROKEN_MODELS = True tMarks = np.append(tMarks, time.time()) + Params.EXPLOREOGRAM_IN_PROGRESS = True PlanetGrid = ParPlanetExplore(Planet, Params, xList, yList) + Params.EXPLOREOGRAM_IN_PROGRESS = False tMarks = np.append(tMarks, time.time()) dt = tMarks[-1] - tMarks[-2] log.info(f'Parallel run elapsed time: {dt:.1f} s.') @@ -1616,83 +1732,26 @@ def ExploreOgram(bodyname, Params, fNameOverride=None, RETURN_GRID=False, Magnet PlanetList = np.reshape(PlanetGrid, -1) PlanetList, Params = GetLayerMeans(PlanetList, Params) PlanetGrid = np.reshape(PlanetList, gridShape) + + Exploration = ExtractResults(Exploration, PlanetGrid, Params) - # Organize data into a format that can be plotted/saved for plotting - Exploration.bodyname = bodyname - Exploration.NO_H2O = PlanetGrid[0,0].Do.NO_H2O - Exploration.CMR2str = f'$C/MR^2 = {PlanetGrid[0,0].CMR2str}$' - Exploration.Cmeasured = PlanetGrid[0,0].Bulk.Cmeasured - Exploration.Cupper = PlanetGrid[0,0].Bulk.CuncertaintyUpper - Exploration.Clower = PlanetGrid[0,0].Bulk.CuncertaintyLower - Exploration.wOcean_ppt = np.array([[Planeti.Ocean.wOcean_ppt for Planeti in line] for line in PlanetGrid]) - Exploration.oceanComp = np.array([[Planeti.Ocean.comp for Planeti in line] for line in PlanetGrid]) - Exploration.R_m = np.array([[Planeti.Bulk.R_m for Planeti in line] for line in PlanetGrid]) - Exploration.Tb_K = np.array([[Planeti.Bulk.Tb_K for Planeti in line] for line in PlanetGrid]) - Exploration.zb_approximate_km = np.array([[Planeti.Bulk.zb_approximate_km for Planeti in line] for line in PlanetGrid]) - Exploration.xFeS = np.array([[Planeti.Core.xFeS for Planeti in line] for line in PlanetGrid]) - Exploration.rhoSilInput_kgm3 = np.array([[Planeti.Sil.rhoSilWithCore_kgm3 for Planeti in line] for line in PlanetGrid]) - Exploration.silPhi_frac = np.array([[Planeti.Sil.phiRockMax_frac for Planeti in line] for line in PlanetGrid]) - Exploration.silPhiCalc_frac = np.array([[Planeti.Sil.phiCalc_frac for Planeti in line] for line in PlanetGrid]) - Exploration.phiSeafloor_frac = np.array([[Planeti.phiSeafloor_frac for Planeti in line] for line in PlanetGrid]) - Exploration.icePhi_frac = np.array([[Planeti.Ocean.phiMax_frac['Ih'] for Planeti in line] for line in PlanetGrid]) - Exploration.silPclosure_MPa = np.array([[Planeti.Sil.Pclosure_MPa for Planeti in line] for line in PlanetGrid]) - Exploration.icePclosure_MPa = np.array([[Planeti.Ocean.Pclosure_MPa['Ih'] for Planeti in line] for line in PlanetGrid]) - Exploration.ionosTop_km = np.array([[Planeti.Magnetic.ionosBounds_m[-1]/1e3 for Planeti in line] for line in PlanetGrid]) - Exploration.sigmaIonos_Sm = np.array([[Planeti.Magnetic.sigmaIonosPedersen_Sm[-1] for Planeti in line] for line in PlanetGrid]) - Exploration.Htidal_Wm3 = np.array([[Planeti.Sil.Htidal_Wm3 for Planeti in line] for line in PlanetGrid]) - Exploration.Qrad_Wkg = np.array([[Planeti.Sil.Qrad_Wkg for Planeti in line] for line in PlanetGrid]) - Exploration.rhoOceanMean_kgm3 = np.array([[Planeti.Ocean.rhoMean_kgm3 for Planeti in line] for line in PlanetGrid]) - Exploration.rhoSilMean_kgm3 = np.array([[Planeti.Sil.rhoMean_kgm3 for Planeti in line] for line in PlanetGrid]) - Exploration.rhoCoreMean_kgm3 = np.array([[Planeti.Core.rhoMean_kgm3 for Planeti in line] for line in PlanetGrid]) - Exploration.sigmaMean_Sm = np.array([[Planeti.Ocean.sigmaMean_Sm for Planeti in line] for line in PlanetGrid]) - Exploration.sigmaTop_Sm = np.array([[Planeti.Ocean.sigmaTop_Sm for Planeti in line] for line in PlanetGrid]) - Exploration.Tmean_K = np.array([[Planeti.Ocean.Tmean_K for Planeti in line] for line in PlanetGrid]) - Exploration.D_km = np.array([[Planeti.D_km for Planeti in line] for line in PlanetGrid]) - Exploration.zb_km = np.array([[Planeti.zb_km for Planeti in line] for line in PlanetGrid]) - Exploration.zSeafloor_km = Exploration.zb_km + Exploration.D_km - Exploration.dzIceI_km = np.array([[Planeti.dzIceI_km for Planeti in line] for line in PlanetGrid]) - Exploration.dzClath_km = np.array([[Planeti.dzClath_km for Planeti in line] for line in PlanetGrid]) - Exploration.dzIceIII_km = np.array([[Planeti.dzIceIII_km for Planeti in line] for line in PlanetGrid]) - Exploration.dzIceIIIund_km = np.array([[Planeti.dzIceIIIund_km for Planeti in line] for line in PlanetGrid]) - Exploration.dzIceV_km = np.array([[Planeti.dzIceV_km for Planeti in line] for line in PlanetGrid]) - Exploration.dzIceVund_km = np.array([[Planeti.dzIceVund_km for Planeti in line] for line in PlanetGrid]) - Exploration.dzIceVI_km = np.array([[Planeti.dzIceVI_km for Planeti in line] for line in PlanetGrid]) - Exploration.dzWetHPs_km = np.array([[Planeti.dzWetHPs_km for Planeti in line] for line in PlanetGrid]) - Exploration.eLid_km = np.array([[Planeti.eLid_m/1e3 for Planeti in line] for line in PlanetGrid]) - Exploration.Rcore_km = np.array([[Planeti.Core.Rmean_m/1e3 for Planeti in line] for line in PlanetGrid]) - Exploration.Pseafloor_MPa = np.array([[Planeti.Pseafloor_MPa for Planeti in line] for line in PlanetGrid]) - Exploration.qSurf_Wm2 = np.array([[Planeti.qSurf_Wm2 for Planeti in line] for line in PlanetGrid]) - Exploration.h_love_number = np.array([[Planeti.Gravity.h for Planeti in line] for line in PlanetGrid]) - Exploration.l_love_number = np.array([[Planeti.Gravity.l for Planeti in line] for line in PlanetGrid]) - Exploration.k_love_number = np.array([[Planeti.Gravity.k for Planeti in line] for line in PlanetGrid]) - Exploration.affinityMean_kJ = np.array([[Planeti.Ocean.affinityMean_kJ for Planeti in line] for line in PlanetGrid]) - Exploration.affinitySeafloor_kJ = np.array([[Planeti.Ocean.affinitySeafloor_kJ for Planeti in line] for line in PlanetGrid]) - Exploration.delta_love_number_relation = np.array([[Planeti.Gravity.delta for Planeti in line] for line in PlanetGrid]) - Exploration.CMR2calc = np.array([[Planeti.CMR2mean for Planeti in line] for line in PlanetGrid]) - Exploration.VALID = np.array([[Planeti.Do.VALID for Planeti in line] for line in PlanetGrid]) - Exploration.invalidReason = np.array([[Planeti.invalidReason for Planeti in line] for line in PlanetGrid]) - if not np.any(Exploration.VALID): + + if not np.any(Exploration.base.VALID): log.warning('No valid models appeared for the given input settings in this ExploreOgram.') - # Ensure everything is set so things will play nicely with .mat saving and plotting functions - nans = np.nan * Exploration.R_m - for name, attr in Exploration.__dict__.items(): - if attr is None: - setattr(Exploration, name, nans) - Params.DataFiles = DataFiles Params.FigureFiles = FigureFiles - WriteExploreOgram(Exploration, Params) + WriteResults(Exploration, Params.DataFiles.exploreOgramFile, Params.SAVE_AS_MATLAB, Params.DataFiles.exploreOgramMatFile) else: log.info(f'Reloading explore-o-gram for {bodyname}.') if RETURN_GRID: - Planet, Exploration, Params = ReloadExploreOgram(bodyname, Params, fNameOverride=fNameOverride, RETURN_PLANET = True) + Exploration, Planet, Params = ReloadExploreOgram(bodyname, Params, fNameOverride=fNameOverride, RETURN_PLANET = True) PlanetGrid = np.empty((Params.Explore.nx, Params.Explore.ny), dtype=object) # Get the parameter arrays from the exploration object - x_attr = getattr(Exploration, Params.Explore.xName, None) - y_attr = getattr(Exploration, Params.Explore.yName, None) - valid_attr = getattr(Exploration, 'VALID', None) + x_attr = getattr(Exploration.base, Params.Explore.xName) + y_attr = getattr(Exploration.base, Params.Explore.yName) + valid_attr = getattr(Exploration.base, 'VALID') if x_attr is None or y_attr is None: raise ValueError(f"Unable to find parameter {Params.Explore.xName} or {Params.Explore.yName} in Exploration object") @@ -1708,9 +1767,10 @@ def ExploreOgram(bodyname, Params, fNameOverride=None, RETURN_GRID=False, Magnet PlanetGrid[i,j] = AssignPlanetVal(PlanetGrid[i,j], Params.Explore.yName, y_attr[i,j]) PlanetGrid[i,j].Do.VALID = valid_attr[i,j] # Load the profiles for the valid models + Params.DO_EXPLOREOGRAM = False for Planet in PlanetGrid.flatten(): - if Planet.Do.VALID: - Planet, _ = ReloadProfile(Planet, Params) + Planet, _ = PlanetProfile(Planet, deepcopy(Params)) + Params.DO_EXPLOREOGRAM = True else: Exploration, Params = ReloadExploreOgram(bodyname, Params, fNameOverride=fNameOverride) @@ -1739,79 +1799,134 @@ def AssignPlanetVal(Planet, name, val): qSurf_Wm2: Surface heat flux for waterless bodies in Planet.Bulk.qSurf_Wm2 ionosTop_km: Ionosphere upper limit altitude above the surface in km, used in Planet.Magnetic.ionosBounds_m. sigmaIonos_Sm: Ionosphere Pedersen conductivity in S/m in Planet.Magnetic.sigmaIonosPedersen_Sm. + + Monte Carlo non-self-consistent parameters: + dzIceI_km: Ice shell thickness in km in Planet.dzIceI_km + D_km: Ocean thickness in km in Planet.D_km + Core_R_km: Core radius in km (converted to m in Planet.Core.Rmean_m) + rho_iceCond_kgm3: Conductive ice density in kg/m³ in Planet.Ocean.rhoCondMean_kgm3['Ih'] + rho_iceConv_kgm3: Convective ice density in kg/m³ in Planet.Ocean.rhoConvMean_kgm3['Ih'] + rho_ocean_kgm3: Ocean density in kg/m³ in Planet.Ocean.rhoMean_kgm3 + rho_sil_kgm3: Silicate density in kg/m³ in Planet.Sil.rhoMean_kgm3 + rho_core_kgm3: Core density in kg/m³ in Planet.Core.rhoMean_kgm3 + GS_ice_GPa: Ice shear modulus in GPa in Planet.Ocean.GScondMean_GPa['Ih'] + GS_sil_GPa: Silicate shear modulus in GPa in Planet.Sil.GSmean_GPa + GS_core_GPa: Core shear modulus in GPa in Planet.Core.GSmean_GPa """ - + if name == 'R_m': Planet.Bulk.R_m = val - elif name == 'xFeS': - Planet.Core.xFeS = val - Planet.Do.CONSTANT_INNER_DENSITY = True - elif name == 'rhoSilInput_kgm3': - Planet.Sil.rhoSilWithCore_kgm3 = val - Planet.Do.CONSTANT_INNER_DENSITY = True - elif name == 'wOcean_ppt': - Planet.Ocean.wOcean_ppt = val - elif name == 'Tb_K': - Planet.Bulk.Tb_K = val - elif name == 'zb_approximate_km': - Planet.Bulk.zb_approximate_km = val - Planet.Do.ICEIh_THICKNESS = True - elif name == 'ionosTop_km' or name == 'sigmaIonos_Sm': - # Make sure ionosphere top altitude and conductivity are both set and valid - if Planet.Magnetic.ionosBounds_m is None or np.any(np.isnan(Planet.Magnetic.ionosBounds_m)): - Planet.Magnetic.ionosBounds_m = [Constants.ionosTopDefault_km*1e3] - elif not isinstance(Planet.Magnetic.ionosBounds_m, Iterable): - Planet.Magnetic.ionosBounds_m = [Planet.Magnetic.ionosBounds_m] - - if Planet.Magnetic.sigmaIonosPedersen_Sm is None or np.any(np.isnan(Planet.Magnetic.sigmaIonosPedersen_Sm)): - Planet.Magnetic.sigmaIonosPedersen_Sm = [Constants.sigmaIonosPedersenDefault_Sm] - elif not isinstance(Planet.Magnetic.sigmaIonosPedersen_Sm, Iterable): - Planet.Magnetic.sigmaIonosPedersen_Sm = [Planet.Magnetic.sigmaIonosPedersen_Sm] - - if name == 'ionosTop_km': - Planet.Magnetic.ionosBounds_m[-1] = val*1e3 - else: - Planet.Magnetic.sigmaIonosPedersen_Sm[-1] = val - elif name == 'silPhi_frac': - Planet.Sil.phiRockMax_frac = val - if val == 0: - Planet.Do.POROUS_ROCK = False - else: + elif not Planet.Do.NON_SELF_CONSISTENT: + if name == 'xFeS': + Planet.Core.xFeS = val + Planet.Do.CONSTANT_INNER_DENSITY = True + elif name == 'rhoSilInput_kgm3': + Planet.Sil.rhoSilWithCore_kgm3 = val + Planet.Do.CONSTANT_INNER_DENSITY = True + elif name == 'wOcean_ppt': + Planet.Ocean.wOcean_ppt = val + elif name == 'Tb_K': + Planet.Bulk.Tb_K = val + elif name == 'zb_approximate_km': + Planet.Bulk.zb_approximate_km = val + Planet.Do.ICEIh_THICKNESS = True + elif name == 'ionosTop_km' or name == 'sigmaIonos_Sm': + # Make sure ionosphere top altitude and conductivity are both set and valid + if Planet.Magnetic.ionosBounds_m is None or np.any(np.isnan(Planet.Magnetic.ionosBounds_m)): + Planet.Magnetic.ionosBounds_m = [Constants.ionosTopDefault_km*1e3] + elif not isinstance(Planet.Magnetic.ionosBounds_m, Iterable): + Planet.Magnetic.ionosBounds_m = [Planet.Magnetic.ionosBounds_m] + + if Planet.Magnetic.sigmaIonosPedersen_Sm is None or np.any(np.isnan(Planet.Magnetic.sigmaIonosPedersen_Sm)): + Planet.Magnetic.sigmaIonosPedersen_Sm = [Constants.sigmaIonosPedersenDefault_Sm] + elif not isinstance(Planet.Magnetic.sigmaIonosPedersen_Sm, Iterable): + Planet.Magnetic.sigmaIonosPedersen_Sm = [Planet.Magnetic.sigmaIonosPedersen_Sm] + + if name == 'ionosTop_km': + Planet.Magnetic.ionosBounds_m[-1] = val*1e3 + else: + Planet.Magnetic.sigmaIonosPedersen_Sm[-1] = val + elif name == 'silPhi_frac': + Planet.Sil.phiRockMax_frac = val Planet.Do.POROUS_ROCK = True - Planet.Do.CONSTANT_INNER_DENSITY = False - elif name == 'silPclosure_MPa': - Planet.Sil.Pclosure_MPa = val - Planet.Do.POROUS_ROCK = True - Planet.Do.CONSTANT_INNER_DENSITY = False - elif name == 'icePhi_frac': - Planet.Ocean.phiMax_frac = {key: val for key in Planet.Ocean.phiMax_frac.keys()} - if val == 0: - Planet.Do.POROUS_ICE = False - else: + Planet.Do.CONSTANT_INNER_DENSITY = False + elif name == 'silPclosure_MPa': + Planet.Sil.Pclosure_MPa = val + Planet.Do.POROUS_ROCK = True + Planet.Do.CONSTANT_INNER_DENSITY = False + elif name == 'icePhi_frac': + Planet.Ocean.phiMax_frac = {key: val for key in Planet.Ocean.phiMax_frac.keys()} Planet.Do.POROUS_ICE = True - elif name == 'icePclosure_MPa': - Planet.Ocean.Pclosure_MPa = {key: val for key in Planet.Ocean.Pclosure_MPa.keys()} - Planet.Do.POROUS_ICE = True - elif name == 'Htidal_Wm3': - Planet.Sil.Htidal_Wm3 = val - elif name == 'Qrad_Wkg': - Planet.Sil.Qrad_Wkg = val - elif name == 'qSurf_Wm2': - Planet.Bulk.qSurf_Wm2 = val - elif name == 'oceanComp': - Planet.Ocean.comp = val - elif name == 'compSil': - Planet.Sil.mantleEOS = val - Planet.Do.CONSTANT_INNER_DENSITY = False - elif name == 'compFe': - Planet.Core.coreEOS = val - Planet.Do.CONSTANT_INNER_DENSITY = False - elif name == 'wFeCore_ppt': - Planet.Core.wFe_ppt = val - Planet.Core.coreEOS = 'Fe-S_3D_EOS.mat' - Planet.Do.CONSTANT_INNER_DENSITY = False + elif name == 'icePclosure_MPa': + Planet.Ocean.Pclosure_MPa = {key: val for key in Planet.Ocean.Pclosure_MPa.keys()} + Planet.Do.POROUS_ICE = True + elif name == 'Htidal_Wm3': + Planet.Sil.Htidal_Wm3 = val + elif name == 'Qrad_Wkg': + Planet.Sil.Qrad_Wkg = val + elif name == 'qSurf_Wm2': + Planet.Bulk.qSurf_Wm2 = val + elif name == 'oceanComp': + Planet.Ocean.comp = val + elif name == 'compSil': + Planet.Sil.mantleEOS = val + Planet.Do.CONSTANT_INNER_DENSITY = False + elif name == 'compFe': + Planet.Core.coreEOS = val + Planet.Do.CONSTANT_INNER_DENSITY = False + elif name == 'wFeCore_ppt': + Planet.Core.wFe_ppt = val + Planet.Core.coreEOS = 'Fe-S_3D_EOS.mat' + Planet.Do.CONSTANT_INNER_DENSITY = False + elif name == 'mixingRatioToH2O': + if Planet.Ocean.Reaction.speciesRatioToChange is None: + raise ValueError(f'Planet.Ocean.Reaction.speciesRatioToChange is not set but you are trying to explore over mixingRatioToH2O. Please set it to the species you want to change the ratio of.') + Planet.Ocean.Reaction.speciesToChangeMixingRatio = val + Planet.Ocean.Reaction.mixingRatioToH2O[Planet.Ocean.Reaction.speciesRatioToChange] = Planet.Ocean.Reaction.speciesToChangeMixingRatio else: - log.warning(f'No defined behavior for Planet setting named "{name}". Returning unchanged.') + # Monte Carlo non-self-consistent parameters + if name == 'dzIceI_km': + Planet.dzIceI_km = val + elif name == 'D_km': + Planet.D_km = val + elif name == 'Core_R_km': + Planet.Core.Rmean_m = val * 1e3 # Convert km to m + elif name == 'rho_iceIhCond_kgm3': + Planet.Ocean.rhoCondMean_kgm3['Ih'] = val + elif name == 'rho_iceIhConv_kgm3': + Planet.Ocean.rhoConvMean_kgm3['Ih'] = val + elif name == 'rho_ocean_kgm3': + Planet.Ocean.rhoMean_kgm3 = val + elif name == 'rho_sil_kgm3': + Planet.Sil.rhoMean_kgm3 = val + elif name == 'rho_core_kgm3': + Planet.Core.rhoMean_kgm3 = val + elif name == 'GS_condIh_GPa': + Planet.Ocean.GScondMean_GPa['Ih'] = val + elif name == 'GS_convIh_GPa': + Planet.Ocean.GSconvMean_GPa['Ih'] = val + elif name == 'GS_sil_GPa': + Planet.Sil.GSmean_GPa = val + elif name == 'GS_core_GPa': + Planet.Core.GSmean_GPa = val + elif name == 'kThermWater_WmK': + Planet.Ocean.kThermWater_WmK = val + elif name == 'kThermIceIh_WmK': + Planet.Ocean.kThermIce_WmK['Ih'] = val + elif name == 'kThermCore_WmK': + Planet.Core.kTherm_WmK = val + elif name == 'etaSil_Pas': + Planet.Sil.etaRock_Pas = val + elif name == 'etaMelt_Pas': + Planet.etaMelt_Pas = val + elif name == 'TSurf_K': + Planet.Bulk.TSurf_K = val + elif name == 'EactIceIh_kJmol': + Planet.Ocean.Eact_kJmol['Ih'] = val + elif name == 'AndradeExponent': + Planet.Gravity.andradExponent = val + else: + log.warning(f'No defined behavior for Planet setting named "{name}". Returning unchanged.') # Do some final checks to ensure we have set all variables correctly if Planet.Do.POROUS_ROCK: @@ -1825,85 +1940,6 @@ def AssignPlanetVal(Planet, name, val): return Planet -def WriteExploreOgram(Exploration, Params, INVERSION=False): - """ Organize Exploration results from an explore-o-gram run into a dict - and print to a .mat file. - """ - - saveDict = { - 'bodyname': Exploration.bodyname, - 'NO_H2O': Exploration.NO_H2O, - 'CMR2str': Exploration.CMR2str, - 'Cmeasured': Exploration.Cmeasured, - 'Cupper': Exploration.Cupper, - 'Clower': Exploration.Clower, - 'xName': Exploration.xName, - 'yName': Exploration.yName, - 'wOcean_ppt': Exploration.wOcean_ppt, - 'oceanComp': Exploration.oceanComp, - 'R_m': Exploration.R_m, - 'Tb_K': Exploration.Tb_K, - 'zb_approximate_km': Exploration.zb_approximate_km, - 'xFeS': Exploration.xFeS, - 'rhoSilInput_kgm3': Exploration.rhoSilInput_kgm3, - 'silPhi_frac': Exploration.silPhi_frac, - 'icePhi_frac': Exploration.icePhi_frac, - 'silPclosure_MPa': Exploration.silPclosure_MPa, - 'icePclosure_MPa': Exploration.icePclosure_MPa, - 'ionosTop_km': Exploration.ionosTop_km, - 'sigmaIonos_Sm': Exploration.sigmaIonos_Sm, - 'Htidal_Wm3': Exploration.Htidal_Wm3, - 'Qrad_Wkg': Exploration.Qrad_Wkg, - 'rhoOceanMean_kgm3': Exploration.rhoOceanMean_kgm3, - 'rhoSilMean_kgm3': Exploration.rhoSilMean_kgm3, - 'rhoCoreMean_kgm3': Exploration.rhoCoreMean_kgm3, - 'sigmaMean_Sm': Exploration.sigmaMean_Sm, - 'sigmaTop_Sm': Exploration.sigmaTop_Sm, - 'Tmean_K': Exploration.Tmean_K, - 'D_km': Exploration.D_km, - 'zb_km': Exploration.zb_km, - 'zSeafloor_km': Exploration.zSeafloor_km, - 'dzIceI_km': Exploration.dzIceI_km, - 'dzClath_km': Exploration.dzClath_km, - 'dzIceIII_km': Exploration.dzIceIII_km, - 'dzIceIIIund_km': Exploration.dzIceIIIund_km, - 'dzIceV_km': Exploration.dzIceV_km, - 'dzIceVund_km': Exploration.dzIceVund_km, - 'dzIceVI_km': Exploration.dzIceVI_km, - 'dzWetHPs_km': Exploration.dzWetHPs_km, - 'eLid_km': Exploration.eLid_km, - 'Rcore_km': Exploration.Rcore_km, - 'silPhiCalc_frac': Exploration.silPhiCalc_frac, - 'Pseafloor_MPa': Exploration.Pseafloor_MPa, - 'phiSeafloor_frac': Exploration.phiSeafloor_frac, - 'h_love_number': Exploration.h_love_number, - 'l_love_number': Exploration.l_love_number, - 'k_love_number': Exploration.k_love_number, - 'affinityMean_kJ': Exploration.affinityMean_kJ, - 'affinitySeafloor_kJ': Exploration.affinitySeafloor_kJ, - 'delta_love_number_relation': Exploration.delta_love_number_relation, - 'CMR2calc': Exploration.CMR2calc, - 'VALID': Exploration.VALID, - 'invalidReason': Exploration.invalidReason - } - - if INVERSION: - saveDict['Amp'] = Exploration.Amp - saveDict['phase'] = Exploration.phase - saveDict['RMSe'] = Exploration.RMSe - saveDict['chiSquared'] = Exploration.chiSquared - saveDict['stdDev'] = Exploration.stdDev - saveDict['Rsquared'] = Exploration.Rsquared - fName = Params.DataFiles.invertOgramFile - else: - fName = Params.DataFiles.exploreOgramFile - - savemat(fName, saveDict) - log.info(f'Saved explore-o-gram {fName} to disk.') - - return - - def ReloadExploreOgram(bodyname, Params, fNameOverride=None, INVERSION=False, RETURN_PLANET = False): """ Reload a previously run explore-o-gram from disk. """ @@ -1914,91 +1950,20 @@ def ReloadExploreOgram(bodyname, Params, fNameOverride=None, INVERSION=False, RE else: loadname = bodyname bodydir = bodyname - + fName = fNameOverride Planet = importlib.import_module(f'{bodydir}.PP{loadname}Explore').Planet else: - if '.mat' in fNameOverride: - reload = loadmat(fNameOverride) - else: - bodydir = bodyname - Planet = importlib.import_module(f'{bodydir}.{fNameOverride[:-3]}').Planet - - # Common setup for fnames that are not .mat already - if not (fNameOverride and '.mat' in fNameOverride): - Planet, Params.DataFiles, Params.FigureFiles = SetupFilenames(Planet, Params, - exploreAppend=f'{Params.Explore.xName}{Params.Explore.xRange[0]}_{Params.Explore.xRange[1]}_{Params.Explore.yName}{Params.Explore.yRange[0]}_{Params.Explore.yRange[1]}', - figExploreAppend=Params.Explore.zName) - fName = Params.DataFiles.invertOgramFile if INVERSION else Params.DataFiles.exploreOgramFile - reload = loadmat(fName) + bodydir = bodyname + Planet = importlib.import_module(f'{bodydir}.{fNameOverride[:-3]}').Planet + Planet, Params.DataFiles, Params.FigureFiles = SetupFilenames(Planet, Params, + exploreAppend=f'{Params.Explore.xName}{Params.Explore.xRange[0]}_{Params.Explore.xRange[1]}_{Params.Explore.yName}{Params.Explore.yRange[0]}_{Params.Explore.yRange[1]}', + figExploreAppend=Params.Explore.zName) + fName = Params.DataFiles.invertOgramFile if INVERSION else Params.DataFiles.exploreOgramFile + Exploration = ReloadResultsFromPickle(fName) - Exploration = ExplorationStruct() - Exploration.bodyname = reload['bodyname'][0] - Exploration.NO_H2O = reload['NO_H2O'][0] - Exploration.CMR2str = reload['CMR2str'][0] - Exploration.Cmeasured = reload['Cmeasured'][0] - Exploration.Cupper = reload['Cupper'][0] - Exploration.Clower = reload['Clower'][0] - Exploration.xName = reload['xName'][0] - Exploration.yName = reload['yName'][0] - Exploration.wOcean_ppt = reload['wOcean_ppt'] - Exploration.oceanComp = reload['oceanComp'] - Exploration.oceanComp = np.char.rstrip(Exploration.oceanComp) - Exploration.R_m = reload['R_m'] - Exploration.Tb_K = reload['Tb_K'] - Exploration.zb_approximate_km = reload['zb_approximate_km'] - Exploration.xFeS = reload['xFeS'] - Exploration.rhoSilInput_kgm3 = reload['rhoSilInput_kgm3'] - Exploration.silPhi_frac = reload['silPhi_frac'] - Exploration.icePhi_frac = reload['icePhi_frac'] - Exploration.silPclosure_MPa = reload['silPclosure_MPa'] - Exploration.icePclosure_MPa = reload['icePclosure_MPa'] - Exploration.ionosTop_km = reload['ionosTop_km'] - Exploration.sigmaIonos_Sm = reload['sigmaIonos_Sm'] - Exploration.Htidal_Wm3 = reload['Htidal_Wm3'] - Exploration.Qrad_Wkg = reload['Qrad_Wkg'] - Exploration.rhoOceanMean_kgm3 = reload['rhoOceanMean_kgm3'] - Exploration.rhoSilMean_kgm3 = reload['rhoSilMean_kgm3'] - Exploration.rhoCoreMean_kgm3 = reload['rhoCoreMean_kgm3'] - Exploration.sigmaMean_Sm = reload['sigmaMean_Sm'] - Exploration.sigmaTop_Sm = reload['sigmaTop_Sm'] - Exploration.Tmean_K = reload['Tmean_K'] - Exploration.D_km = reload['D_km'] - Exploration.zb_km = reload['zb_km'] - Exploration.zSeafloor_km = reload['zSeafloor_km'] - Exploration.dzIceI_km = reload['dzIceI_km'] - Exploration.dzClath_km = reload['dzClath_km'] - Exploration.dzIceIII_km = reload['dzIceIII_km'] - Exploration.dzIceIIIund_km = reload['dzIceIIIund_km'] - Exploration.dzIceV_km = reload['dzIceV_km'] - Exploration.dzIceVund_km = reload['dzIceVund_km'] - Exploration.dzIceVI_km = reload['dzIceVI_km'] - Exploration.dzWetHPs_km = reload['dzWetHPs_km'] - Exploration.eLid_km = reload['eLid_km'] - Exploration.Rcore_km = reload['Rcore_km'] - Exploration.Pseafloor_MPa = reload['Pseafloor_MPa'] - Exploration.phiSeafloor_frac = reload['phiSeafloor_frac'] - Exploration.silPhiCalc_frac = reload['silPhiCalc_frac'] - Exploration.h_love_number = reload['h_love_number'] - Exploration.l_love_number = reload['l_love_number'] - Exploration.k_love_number = reload['k_love_number'] - Exploration.affinityMean_kJ = reload['affinityMean_kJ'] - Exploration.affinitySeafloor_kJ = reload['affinitySeafloor_kJ'] - Exploration.delta_love_number_relation = 1 + Exploration.k_love_number - Exploration.h_love_number - # Exploration.delta_love_number_relation = reload['delta_love_number_relation'] - Exploration.CMR2calc = reload['CMR2calc'] - Exploration.VALID = reload['VALID'] - Exploration.invalidReason = reload['invalidReason'] - - if INVERSION: - Exploration.Amp = reload['Amp'] - Exploration.phase = reload['phase'] - Exploration.RMSe = reload['RMSe'] - Exploration.chiSquared = reload['chiSquared'] - Exploration.stdDev = reload['stdDev'] - Exploration.Rsquared = reload['Rsquared'] if RETURN_PLANET: - return Planet, Exploration, Params + return Exploration, Planet, Params else: return Exploration, Params @@ -2068,6 +2033,7 @@ def RunPPfile(bodyname, fName, Params=None): return Planet, Params + if __name__ == '__main__': # Command line args nArgs = len(sys.argv) diff --git a/PlanetProfile/Model/defaultConfigModel.py b/PlanetProfile/Model/defaultConfigModel.py deleted file mode 100644 index 97fd3e7b..00000000 --- a/PlanetProfile/Model/defaultConfigModel.py +++ /dev/null @@ -1,110 +0,0 @@ -""" Default ice model parameters for alternative (non-self-consistent) ice layer modeling """ -from PlanetProfile.Utilities.defineStructs import ModelSubstruct - -configModelVersion = 1 # Integer number for config file version. Increment when new settings are added to the default config file. - -def modelAssign(): - ModelParams = ModelSubstruct() - - # Specify parameters for each ice phase - # Each parameter can be specified as a constant value, a profile (list/array), or None (to use default calculation) - # Keys must match the phase names used in the codebase: "Ih", "III", "V" - - # Temperature profiles (in K) - # If None, the default conductive profile will be used - ModelParams.T_K = { - "Ih": None, # Default conductive profile: T_K = Tb_K**(Pratios) * Tsurf_K**(1 - Pratios) - "III": None, # If specified, provide array of length nIceIIILitho or a constant value - "V": None # If specified, provide array of length nIceVLitho or a constant value - } - - # Pressure profiles (in MPa) - # If None, the default linear profile will be used - ModelParams.P_MPa = { - "Ih": None, # Default: linear from Psurf_MPa to PbI_MPa - "III": None, # Default: linear from PbI_MPa to PbIII_MPa - "V": None # Default: linear from PbIII_MPa to PbV_MPa - } - - # Density profiles (in kg/m³) - # If None, values will be calculated from the EOS based on P and T - ModelParams.rho_kgm3 = { - "Ih": None, - "III": None, - "V": None - } - - # Heat capacity profiles (in J/kg/K) - # If None, values will be calculated from the EOS based on P and T - ModelParams.Cp_JkgK = { - "Ih": None, - "III": None, - "V": None - } - - # Thermal expansivity profiles (in 1/K) - # If None, values will be calculated from the EOS based on P and T - ModelParams.alpha_pK = { - "Ih": None, - "III": None, - "V": None - } - - # Thermal conductivity profiles (in W/m/K) - # If None, values will be calculated based on default models - ModelParams.kTherm_WmK = { - "Ih": None, - "III": None, - "V": None - } - - # Porosity profiles (as fractions, not percentages) - # If None, values will be calculated based on default porosity models if POROUS_ICE is True - ModelParams.phi_frac = { - "Ih": None, - "III": None, - "V": None - } - - # Electrical conductivity profiles (in S/m) - # If None, values will be calculated based on default conductivity models if CALC_CONDUCT is True - ModelParams.sigma_Sm = { - "Ih": None, - "III": None, - "V": None - } - - # Tidal heating profiles (in W/m³) - # If None, tidal heating values will be calculated or applied from Planet settings - ModelParams.Htidal_Wm3 = { - "Ih": None, - "III": None, - "V": None - } - - # Boundary temperature overrides (in K) - # If None, use values from Planet.Bulk - ModelParams.Tb_K = { - "Ih": None, # Same as Planet.Bulk.Tb_K - "III": None, # Same as Planet.Bulk.TbIII_K - "V": None # Same as Planet.Bulk.TbV_K - } - - # Boundary pressure overrides (in MPa) - # If None, use the calculated values from phase transitions - ModelParams.Pb_MPa = { - "Ih": None, # If set, overrides the calculated PbI_MPa - "III": None, # If set, overrides the calculated PbIII_MPa - "V": None # If set, overrides the calculated PbV_MPa - } - - # Shell thickness overrides (in km) - # If provided, these will be used to determine layer thicknesses directly - # instead of calculating from phase transition pressures - ModelParams.thickness_km = { - "Ih": None, - "III": None, - "V": None - } - - return ModelParams diff --git a/PlanetProfile/MonteCarlo/defaultConfigMonteCarlo.py b/PlanetProfile/MonteCarlo/defaultConfigMonteCarlo.py new file mode 100644 index 00000000..39df0464 --- /dev/null +++ b/PlanetProfile/MonteCarlo/defaultConfigMonteCarlo.py @@ -0,0 +1,106 @@ +""" Configuration settings specific to Monte Carlo calculations """ +import numpy as np +from PlanetProfile.Utilities.defineStructs import MonteCarloParamsStruct + +configMonteCarloVersion = 1 # Integer number for config file version. Increment when new settings are added to the default config file. + +def montecarloAssign(): + """ + Assign Monte Carlo parameters for PlanetProfile runs. + """ + MonteCarloParams = MonteCarloParamsStruct() + + # General settings + MonteCarloParams.nRuns = 1000 # Number of Monte Carlo runs to perform + MonteCarloParams.seed = None # Random seed for reproducibility (None for random) + MonteCarloParams.useParallel = True # Whether to use parallel processing + + # Parameters to search over for self-consistent models + MonteCarloParams.paramsToSearchSelfConsistent = [ + 'zb_approximate_km', 'oceanComp' + ] + + # Parameters to search over for non-self-consistent models + MonteCarloParams.paramsToSearchNonSelfConsistent = [ + 'dzIceI_km', 'D_km', 'Core_R_km', 'rho_iceIhCond_kgm3', 'rho_iceIhConv_kgm3', + 'rho_ocean_kgm3', 'rho_sil_kgm3', 'rho_core_kgm3', 'GS_condIh_GPa', 'GS_convIh_GPa', + 'GS_sil_GPa', 'GS_core_GPa', 'kThermWater_WmK', 'kThermIceIh_WmK', 'kThermCore_WmK', + 'etaSil_Pas', 'etaMelt_Pas', 'TSurf_K', 'EactIceIh_kJmol', 'AndradeExponent' + ] + + # Distribution types for each parameter (currently only 'uniform' is supported) + MonteCarloParams.paramsDistributions = { + # Self-consistent parameters + 'R_m': 'uniform', + 'Tb_K': 'uniform', + 'wOcean_ppt': 'uniform', + 'xFeS': 'uniform', + 'rhoSilInput_kgm3': 'uniform', + 'zb_approximate_km': 'uniform', + 'oceanComp': 'discrete', + + # Non-self-consistent parameters + 'dzIceI_km': 'uniform', + 'D_km': 'uniform', + 'Core_R_km': 'uniform', + 'rho_iceIhCond_kgm3': 'uniform', + 'rho_iceIhConv_kgm3': 'uniform', + 'rho_ocean_kgm3': 'uniform', + 'rho_sil_kgm3': 'uniform', + 'rho_core_kgm3': 'uniform', + 'GS_condIh_GPa': 'uniform', + 'GS_convIh_GPa': 'uniform', + 'GS_sil_GPa': 'uniform', + 'GS_core_GPa': 'uniform', + 'kThermWater_WmK': 'uniform', + 'kThermIceIh_WmK': 'uniform', + 'kThermCore_WmK': 'uniform', + 'etaSil_Pas': 'uniform', + 'etaMelt_Pas': 'uniform', + 'TSurf_K': 'uniform', + 'EactIceIh_kJmol': 'uniform', + 'AndradeExponent': 'uniform' + + } + + # Parameter ranges [min, max] for each parameter + MonteCarloParams.paramsRanges = { + # Self-consistent parameters + 'R_m': [1.5e6, 1.6e6], # Body radius in m (Europa range) + 'Tb_K': [260, 272], # Bottom temperature in K + 'wOcean_ppt': [0, 100], # Ocean salinity in ppt + 'xFeS': [0, 1], # Core FeS mole fraction + 'rhoSilInput_kgm3': [2500, 3500], # Silicate density in kg/m^3 + + # Non-self-consistent parameters + 'dzIceI_km': [10, 50], # Ice shell thickness in km + 'D_km': [50, 150], # Ocean thickness in km + 'Core_R_km': [0, 1000], # Core radius in km + 'rho_iceIhCond_kgm3': [850, 950], # Conductive ice density in kg/m^3 + 'rho_iceIhConv_kgm3': [950, 1050], # Convective ice density in kg/m^3 + 'rho_ocean_kgm3': [950, 1050], # Ocean density in kg/m^3 + 'rho_sil_kgm3': [3000, 3500], # Silicate density in kg/m^3 + 'rho_core_kgm3': [5000, 6000], # Core density in kg/m^3 + 'GSConvIh_GPa': [50, 150], # Ice shear modulus in GPa + 'GS_sil_GPa': [30, 70], # Silicate shear modulus in GPa + 'GS_core_GPa': [80, 120], # Core shear modulus in GPa + 'kThermWater_WmK': [0.5, 1.5], # Water thermal conductivity in W/mK + 'kThermIceIh_WmK': [0.5, 1.5], # Ice Ih thermal conductivity in W/mK + 'kThermCore_WmK': [0.5, 1.5], # Core thermal conductivity in W/mK + 'etaSil_Pas': [1e18, 1e20], # Silicate viscosity in Pa s + 'etaMelt_Pas': [1e18, 1e20], # Melt viscosity in Pa s + 'TSurf_K': [260, 272], # Surface temperature in K + 'EactIceIh_kJmol': [0, 100], # Activation energy for ocean in kJ/mol + 'AndradeExponent': [0, 10] # Andrade exponent + } + + # Output settings + MonteCarloParams.saveResults = True # Whether to save results to file + MonteCarloParams.showPlots = True # Whether to display plots + MonteCarloParams.plotDistributions = True # Whether to plot parameter distributions + MonteCarloParams.plotResults = True # Whether to plot Monte Carlo results distributions + MonteCarloParams.plotOceanComps = False # Whether to plot results by ocean composition + MonteCarloParams.plotCorrelations = True # Whether to plot parameter correlations + MonteCarloParams.plotScatter = False # Whether to plot scatter plots of parameter pairs + MonteCarloParams.scatterParams = [['kLoveAmp', 'Amp'], ['hLoveAmp', 'phase']] # Parameter pairs to plot + return MonteCarloParams diff --git a/PlanetProfile/Plotting/EssentialHelpers.py b/PlanetProfile/Plotting/EssentialHelpers.py new file mode 100644 index 00000000..c9a6a4d6 --- /dev/null +++ b/PlanetProfile/Plotting/EssentialHelpers.py @@ -0,0 +1,834 @@ +""" +Essential Plot Helpers - Minimal set for duplicated logic only + +This contains only the essential helper functions for code that is clearly +duplicated across multiple plotting functions. Emphasis on readability +and simplicity over extensibility. +""" + +import numpy as np +import matplotlib.pyplot as plt +import logging +from matplotlib.gridspec import GridSpec +from PlanetProfile.MagneticInduction.Moments import Excitations +from PlanetProfile.GetConfig import Color, Style, FigLbl, FigSize, FigMisc +from PlanetProfile.Utilities.defineStructs import xyzComps, vecComps +from matplotlib.patches import Rectangle, ConnectionPatch +from mpl_toolkits.axes_grid1.inset_locator import inset_axes +log = logging.getLogger('PlanetProfile') + + +def get_excitation_indices_and_names(induction_obj, params_induct): + """ + Extract excitation selection logic from PlotInductOgram for reuse. + + This consolidates the complex excitation filtering logic that appears in: + - PlotInductOgram (MagPlots.py lines ~340-348) + - extract_monte_carlo_parameter_values (current function) + - Other magnetic plotting functions + + Args: + induction_obj: Object with Texc_hr attribute (dict of excitation periods) + params_induct: Parameters object with excSelectionPlot attribute + + Returns: + tuple: (iTexc, iTexcAvail, TexcPlotNames, Texc_hr, iSort) + - iTexc: Indices in full Texc_hr array for selected excitations + - iTexcAvail: Indices in available (finite) Texc_hr array for selected excitations + - TexcPlotNames: Names of excitations to plot + - Texc_hr: Periods of excitations to plot + - iSort: Sort indices for Texc_hr (ascending order) + """ + # Get indices for the oscillations that we can and want to plot + excSelectionCalc = {key: Texc for key, Texc in zip(induction_obj.calcedExc, induction_obj.Texc_hr)} + whichTexc = excSelectionCalc and params_induct.excSelectionPlot + allTexc_hr = np.fromiter(excSelectionCalc.values(), dtype=np.float_) + allAvailableTexc_hr = allTexc_hr[np.isfinite(allTexc_hr)] + iTexc = [np.where(allTexc_hr == Texc)[0][0] for key, Texc in excSelectionCalc.items() + if whichTexc[key] and np.size(np.where(allTexc_hr == Texc)[0]) > 0] + iTexcAvail = [np.where(allAvailableTexc_hr == Texc)[0][0] for key, Texc in excSelectionCalc.items() + if whichTexc[key] and np.size(np.where(allAvailableTexc_hr == Texc)[0]) > 0] + TexcPlotNames = np.fromiter(excSelectionCalc.keys(), dtype=' 0: + + legend = ax.legend(handles=overrideLegend, fontsize=font_size, + title_fontsize=title_font_size) + + else: + # Position legend to avoid overlap with ice thickness legend + if show_ice_thickness_legend: + legend =ax.legend(title="Ocean Composition", fontsize=font_size, + title_fontsize=title_font_size, + loc='upper left', bbox_to_anchor=(0.0, 1.0)) + else: + legend = ax.legend(title="Ocean Composition", fontsize=font_size, + title_fontsize=title_font_size, loc = 'upper left') + ax.add_artist(legend) + + +def extract_and_validate_plot_data(result_obj, x_field, y_field, c_field=None, contour_field=None, + x_multiplier=1.0, y_multiplier=1.0, c_multiplier=1.0, contour_multiplier=1.0, + custom_x_axis = None, custom_y_axis = None): + """ + Extract and validate basic plot data from a result object. + + This follows the original pattern: + x = np.reshape(result.__getattribute__(x_field) * multiplier, -1) + VALID = np.logical_not(np.logical_or(np.isnan(x), np.isnan(y))) + + Args: + result_obj: Result object with hierarchical structure (result.base.field_name) + x_field, y_field: Field names to extract from result.base + c_field: Optional color field name (for colormap) + contour_field: Optional contour field name (for contour lines) + x_multiplier, y_multiplier, c_multiplier, contour_multiplier: Scaling factors + custom_x_axis, custom_y_axis: Custom x and y axes (for example, if we want to plot a custom x axis) + + Returns: + dict: {'x': x_valid, 'y': y_valid, 'c': c_valid, 'contour': contour_valid, 'original_shape': shape} + """ + + # Extract data using original pattern - check base structure + if x_field == result_obj.xName: + x_raw = result_obj.xData + elif hasattr(result_obj.base, x_field): + x_raw = result_obj.base.__dict__[x_field] + else: + raise ValueError(f"Field {x_field} not found in result.base") + # Handle string vs numeric data (like in PlotExploreOgramModern) + if np.issubdtype(x_raw.dtype, np.number): + x = np.reshape(x_raw * x_multiplier, -1) + else: + x = np.array([]) + for i in range(x_raw.shape[0]): + row = np.repeat(int(i), x_raw.shape[1]) + x = np.concatenate((x, row)) + x = x.reshape(-1) + if custom_x_axis is not None: + if len(custom_x_axis) == len(x): + x = custom_x_axis + elif len(custom_x_axis) == x_raw.shape[0]: + x = np.repeat(custom_x_axis, x_raw.shape[1]) + else: + log.warning(f"Custom x axis length {len(custom_x_axis)} does not match x length {len(x)}") + else: + x = x + + if y_field == result_obj.yName: + y_raw = result_obj.yData + elif hasattr(result_obj.base, y_field): + y_raw = result_obj.base.__dict__[y_field] + else: + raise ValueError(f"Field {y_field} not found in result.base") + # Handle string vs numeric data (like in PlotExploreOgramModern) + if np.issubdtype(y_raw.dtype, np.number): + y = np.reshape(y_raw * y_multiplier, -1) + else: + y = np.array([]) + for i in range(y_raw.shape[0]): + y = np.concatenate((y, np.arange(0, y_raw.shape[1], dtype=np.float_))) + y = y.reshape(-1) + if custom_y_axis is not None: + if len(custom_y_axis) == len(y): + y = custom_y_axis + elif len(custom_y_axis) == y_raw.shape[1]: + y = np.tile(custom_y_axis, y_raw.shape[0]) + else: + log.warning(f"Custom y axis length {len(custom_y_axis)} does not match y length {len(y)}") + else: + y = y + + + x_valid = x + y_valid = y + original_shape = x_raw.shape + + # Extract color field if provided + c_valid = None + if c_field is not None: + if 'Induction' in c_field: + c_raw = extract_magnetic_field_data(result_obj, c_field) + elif hasattr(result_obj.base, c_field): + c_raw = result_obj.base.__dict__[c_field] + else: + log.warning(f"Color field {c_field} not found in result.base or result.induction, using default coloring") + # Handle string vs numeric data (like in PlotExploreOgramModern) + if np.issubdtype(c_raw.dtype, np.number): + c = np.reshape(c_raw * c_multiplier, -1) + else: + c = np.reshape(c_raw, -1) # Don't multiply string data + c_valid = c + + # Extract contour field if provided + contour_valid = None + if contour_field is not None: + if hasattr(result_obj.base, contour_field): + contour_raw = result_obj.base.__dict__[contour_field] + # Handle string vs numeric data (like in PlotExploreOgramModern) + if np.issubdtype(contour_raw.dtype, np.number): + contour = np.reshape(contour_raw * contour_multiplier, -1) + else: + contour = np.reshape(contour_raw, -1) # Don't multiply string data + contour_valid = contour + elif hasattr(result_obj, 'induction') and hasattr(result_obj.induction, contour_field): + # Handle magnetic induction fields from 3D data + contour_valid = extract_magnetic_field_data(result_obj, contour_field, contour_multiplier) + else: + log.warning(f"Contour field {contour_field} not found in result.base or result.induction, contours will use color field") + elif contour_field is None and c_field is not None: + contour_valid = c_valid + return { + 'x': x_valid, + 'y': y_valid, + 'c': c_valid, + 'contour': contour_valid, + 'original_shape': original_shape + } + + +def extract_complex_plot_data(result_obj, data_type, Params): + """ + Extract complex number data from result objects for real vs imaginary plotting. + + Handles both magnetic induction and tidal Love numbers automatically. + + Args: + result_obj: Result object with hierarchical structure + data_type: 'magnetic' for magnetic induction or 'love' for love numbers + + Returns: + dict: { + 'data_type': 'magnetic' or 'love', + 'complex_data': {comp: complex_array for comp in components}, + 'excitation_names': [list of excitation names] or ['l2'] for Love, + 'n_peaks': number of peaks/excitations + } + """ + # Auto-detect data type based on available data + has_magnetic = (hasattr(result_obj, 'induction') and + hasattr(result_obj.induction, 'Bi1x_nT') and + result_obj.induction.Bi1x_nT is not None) + has_love = (hasattr(result_obj, 'base') and + hasattr(result_obj.base, 'kLoveComplex') and + result_obj.base.kLoveComplex is not None) + + if data_type == 'magnetic': + if not has_magnetic: + raise ValueError("Magnetic induction data not found") + + complex_data = {} + complex_data['x'] = np.array(result_obj.induction.Bi1x_nT) + complex_data['y'] = np.array(result_obj.induction.Bi1y_nT) + complex_data['z'] = np.array(result_obj.induction.Bi1z_nT) + excitationNamesCalc = [key for key, calc in result_obj.induction.excSelectionCalc.items() + if calc and key != 'none'] + excictationNamesToPlot = [key for key, calc in Params.Induct.excSelectionPlot.items() + if calc and key != 'none'] + excitation_names = [key for key in excitationNamesCalc if key in excictationNamesToPlot] + + + n_peaks = len(excitation_names) + + elif data_type == 'love': + if not has_love: + raise ValueError("Love number data not found") + + complex_data = {} + complex_data['k'] = np.array(result_obj.base.kLoveComplex) + complex_data['h'] = np.array(result_obj.base.hLoveComplex) + complex_data['l'] = np.array(result_obj.base.lLoveComplex) + complex_data['delta'] = np.array(result_obj.base.deltaLoveComplex) + + # Love numbers are typically for l=2 harmonic only + excitation_names = [2] + n_peaks = 1 + + else: + raise ValueError(f"Unknown data_type: {data_type}") + + components = list(complex_data.keys()) + return { + 'data_type': data_type, + 'components': components, + 'complex_data': complex_data, + 'excitation_names': excitation_names, + 'n_peaks': n_peaks + } + + +def normalizeDataForColor(data, dataName, cmap, highlight_mask=None): + """ + Normalize data for color mapping. + + Args: + data: data to normalize + cmap: colormap to use + """ + if dataName in FigLbl.axisLabelsExplore.keys(): + cBarTitle = FigLbl.axisLabelsExplore[dataName] + else: + cBarTitle = dataName + if dataName in FigLbl.cbarfmtExplore.keys(): + cBarFmt = FigLbl.cbarfmtExplore[dataName] + else: + cBarFmt = None + + vmin, vmax = np.nanmin(data), np.nanmax(data) + if vmax > vmin: + norm = plt.Normalize(vmin=vmin, vmax=vmax) + color = cmap(norm(data)) + color = color.reshape(-1, 4) + else: + color = cmap(0.5) + color = np.full((len(data), 4), color) + if highlight_mask is not None: + color[~highlight_mask] = [0, 0, 0, 0] + if dataName in FigLbl.cTicksSpacingsExplore.keys(): + cTicksSpacings = FigLbl.cTicksSpacingsExplore[dataName] + cbar_ticks = np.arange(np.ceil(vmin / cTicksSpacings) * cTicksSpacings, np.floor(vmax / cTicksSpacings) * cTicksSpacings + 0.0001, cTicksSpacings) + else: + cbar_ticks = None + return norm, color, cBarTitle, cBarFmt, cbar_ticks + + +def extract_monte_carlo_parameter_values(result_obj, param_name, excitation_name=None, valid_mask=None): + """ + Extract parameter values from Monte Carlo results with excitation support. + + This consolidates the complex parameter extraction logic that appears + in multiple Monte Carlo plotting functions. Now handles hierarchical structure + with induction substruct for magnetic parameters. + + Args: + result_obj: Monte Carlo results object with hierarchical structure (result.base.*, result.induction.*) + param_name: Name of parameter to extract + excitation_name: Optional excitation name for magnetic parameters (currently not used - field values are pre-calculated) + valid_mask: Optional mask for valid runs + + Returns: + np.array: Parameter values + """ + if valid_mask is None: + # For hierarchical structure, get nRuns from base structure + n_runs = getattr(result_obj.base, 'nRuns', len(result_obj.base.VALID)) + valid_mask = np.ones(n_runs, dtype=bool) + + # Handle magnetic parameters - these are stored in the induction substruct with 3D structure + if param_name in ['Amp', 'Bix_nT', 'Biy_nT', 'Biz_nT', 'Bi_nT', 'phase']: + # Get the parameter values from induction substruct - expect 3D structure (nPeaks, rows, cols) + values = getattr(result_obj.induction, param_name) + values_array = np.array(values) + + if excitation_name is not None: + # Get available excitations to find the correct index + available_excitations = get_available_excitations(result_obj) + exc_index = available_excitations.index(excitation_name) + + # Extract the 2D slice for this excitation: values[exc_index, :, :] + excitation_slice = values_array[exc_index, :, :] + return excitation_slice + else: + # No excitation specified - return first excitation as default + return values_array[0, :, :] + + else: + # Handle regular parameters - these are in the base substruct + values = getattr(result_obj.base, param_name) + return np.array(values) + + +def create_zoom_inset(ax, x_data, y_data, zoom_mask, padding_factor=0.2, + add_visual_indicators=True): + """ + Create an inset zoom plot showing highlighted data points with optional visual indicators. + + Args: + ax: Main axis to add inset to + x_data, y_data: Full data arrays + zoom_mask: Boolean mask for points to zoom in on + padding_factor: Fraction of data range to add as padding + add_visual_indicators: Whether to add rectangle and connection lines + + Returns: + tuple: (inset_ax, optimal_location) or (None, None) if no zoom data + """ + + if not np.any(zoom_mask): + return None, None + + # Get zoom data + x_zoom = x_data[zoom_mask] + y_zoom = y_data[zoom_mask] + + # Calculate zoom limits with padding + x_range = np.max(x_zoom) - np.min(x_zoom) + y_range = np.max(y_zoom) - np.min(y_zoom) + + x_padding = x_range * padding_factor if x_range > 0 else 0.1 + y_padding = y_range * padding_factor if y_range > 0 else 0.1 + + # Check if zoom region is already a large portion of the main axis + # If so, don't create inset as it won't provide meaningful zoom + ax_xlim = ax.get_xlim() + ax_ylim = ax.get_ylim() + + ax_x_range = ax_xlim[1] - ax_xlim[0] + ax_y_range = ax_ylim[1] - ax_ylim[0] + + # Calculate what fraction of the axis the zoom region covers + zoom_x_fraction = (x_range + 2 * x_padding) / ax_x_range if ax_x_range > 0 else 0 + zoom_y_fraction = (y_range + 2 * y_padding) / ax_y_range if ax_y_range > 0 else 0 + + # If zoom region covers more than 50% in both directions, don't create inset + if zoom_x_fraction > 0.5 and zoom_y_fraction > 0.5: + return None, None + + x_min = max(0, np.min(x_zoom) - x_padding) # Ensure non-negative for complex plots + x_max = np.max(x_zoom) + x_padding + y_min = max(0, np.min(y_zoom) - y_padding) # Ensure non-negative for complex plots + y_max = np.max(y_zoom) + y_padding + + # Find optimal inset position based on point density + optimal_location = find_optimal_inset_position(ax, x_data, y_data, inset_size_frac=0.4) + + # Create inset in optimal position + inset_ax = inset_axes(ax, width="50%", height="50%", loc=optimal_location) + inset_ax.set_xlim(x_min, x_max) + inset_ax.set_ylim(y_min, y_max) + + # Dynamically set tick positions based on inset location + # This prevents ticks from overlapping with the main plot data + if 'lower' in optimal_location: + # For lower positions, put x-ticks on top to avoid main plot data + inset_ax.xaxis.tick_top() + inset_ax.xaxis.set_label_position('top') + else: + # For upper positions, keep x-ticks on bottom (default) + inset_ax.xaxis.tick_bottom() + inset_ax.xaxis.set_label_position('bottom') + + if 'right' in optimal_location: + # For right positions, put y-ticks on left to avoid main plot data + inset_ax.yaxis.tick_left() + inset_ax.yaxis.set_label_position('left') + else: + # For left positions, put y-ticks on right + inset_ax.yaxis.tick_right() + inset_ax.yaxis.set_label_position('right') + + # Add visual indicators if requested + if add_visual_indicators: + + try: + # Add rectangle around zoom region on main plot + zoom_rect = Rectangle((x_min, y_min), + x_max - x_min, + y_max - y_min, + fill=False, edgecolor=Color.BdipInset, + linewidth=Style.LW_BdipInset, + linestyle=Style.LS_BdipInset, + zorder=10) + ax.add_patch(zoom_rect) + + # Add border around inset axis + inset_ax.spines['top'].set_color(Color.BdipInset) + inset_ax.spines['top'].set_linewidth(Style.LW_BdipInset * 1.5) + inset_ax.spines['bottom'].set_color(Color.BdipInset) + inset_ax.spines['bottom'].set_linewidth(Style.LW_BdipInset * 1.5) + inset_ax.spines['left'].set_color(Color.BdipInset) + inset_ax.spines['left'].set_linewidth(Style.LW_BdipInset * 1.5) + inset_ax.spines['right'].set_color(Color.BdipInset) + inset_ax.spines['right'].set_linewidth(Style.LW_BdipInset * 1.5) + + # Add smart connection lines based on inset location + if 'upper' in optimal_location: + # Upper insets: connect top of rectangle to bottom of inset + con1 = ConnectionPatch((x_min, y_max), (0, 0), + "data", "axes fraction", + axesA=ax, axesB=inset_ax, + color=Color.BdipInset, + linewidth=Style.LW_BdipInset * 0.8, + linestyle='--', alpha=0.7) + ax.figure.add_artist(con1) + + con2 = ConnectionPatch((x_max, y_max), (1, 0), + "data", "axes fraction", + axesA=ax, axesB=inset_ax, + color=Color.BdipInset, + linewidth=Style.LW_BdipInset * 0.8, + linestyle='--', alpha=0.7) + ax.figure.add_artist(con2) + + elif 'lower right' in optimal_location: + # Lower right inset: connect right of rectangle to left of inset + con1 = ConnectionPatch((x_max, y_min), (0, 0), + "data", "axes fraction", + axesA=ax, axesB=inset_ax, + color=Color.BdipInset, + linewidth=Style.LW_BdipInset * 0.8, + linestyle='--', alpha=0.7) + ax.figure.add_artist(con1) + + con2 = ConnectionPatch((x_max, y_max), (0, 1), + "data", "axes fraction", + axesA=ax, axesB=inset_ax, + color=Color.BdipInset, + linewidth=Style.LW_BdipInset * 0.8, + linestyle='--', alpha=0.7) + ax.figure.add_artist(con2) + + elif 'lower left' in optimal_location: + # Lower left inset: connect left of rectangle to right of inset + con1 = ConnectionPatch((x_min, y_min), (1, 0), + "data", "axes fraction", + axesA=ax, axesB=inset_ax, + color=Color.BdipInset, + linewidth=Style.LW_BdipInset * 0.8, + linestyle='--', alpha=0.7) + ax.figure.add_artist(con1) + + con2 = ConnectionPatch((x_min, y_max), (1, 1), + "data", "axes fraction", + axesA=ax, axesB=inset_ax, + color=Color.BdipInset, + linewidth=Style.LW_BdipInset * 0.8, + linestyle='--', alpha=0.7) + ax.figure.add_artist(con2) + + else: + # Fallback: connect based on position + if 'right' in optimal_location: + # Right side: connect right edge to left of inset + con1 = ConnectionPatch((x_max, y_min), (0, 0), + "data", "axes fraction", + axesA=ax, axesB=inset_ax, + color=Color.BdipInset, + linewidth=Style.LW_BdipInset * 0.8, + linestyle='--', alpha=0.7) + ax.figure.add_artist(con1) + + con2 = ConnectionPatch((x_max, y_max), (0, 1), + "data", "axes fraction", + axesA=ax, axesB=inset_ax, + color=Color.BdipInset, + linewidth=Style.LW_BdipInset * 0.8, + linestyle='--', alpha=0.7) + ax.figure.add_artist(con2) + else: + # Left side: connect left edge to right of inset + con1 = ConnectionPatch((x_min, y_min), (1, 0), + "data", "axes fraction", + axesA=ax, axesB=inset_ax, + color=Color.BdipInset, + linewidth=Style.LW_BdipInset * 0.8, + linestyle='--', alpha=0.7) + ax.figure.add_artist(con1) + + con2 = ConnectionPatch((x_min, y_max), (1, 1), + "data", "axes fraction", + axesA=ax, axesB=inset_ax, + color=Color.BdipInset, + linewidth=Style.LW_BdipInset * 0.8, + linestyle='--', alpha=0.7) + ax.figure.add_artist(con2) + + except Exception as e: + log.debug(f"Could not add visual indicators to zoom inset: {e}") + # Continue without visual indicators if they fail + pass + + return inset_ax, optimal_location + + +def find_optimal_inset_position(ax, x_data, y_data, inset_size_frac=0.4): + """ + Find the optimal position for an inset plot based on point density. + + Args: + ax: Main axis object + x_data, y_data: Full data arrays + inset_size_frac: Fraction of axis space the inset will occupy + + Returns: + str: Best location string for inset_axes + """ + # Get axis limits + xlim = ax.get_xlim() + ylim = ax.get_ylim() + + # Define potential inset positions and their corresponding regions + # Each position maps to a region where we'll count points + positions = { + 'upper right': (xlim[1] - (xlim[1] - xlim[0]) * inset_size_frac, xlim[1], + ylim[1] - (ylim[1] - ylim[0]) * inset_size_frac, ylim[1]), + 'upper left': (xlim[0], xlim[0] + (xlim[1] - xlim[0]) * inset_size_frac, + ylim[1] - (ylim[1] - ylim[0]) * inset_size_frac, ylim[1]), + 'upper center': (xlim[0] + (xlim[1] - xlim[0]) * (0.5 - inset_size_frac/2), + xlim[0] + (xlim[1] - xlim[0]) * (0.5 + inset_size_frac/2), + ylim[1] - (ylim[1] - ylim[0]) * inset_size_frac, ylim[1]), + 'lower right': (xlim[1] - (xlim[1] - xlim[0]) * inset_size_frac, xlim[1], + ylim[0], ylim[0] + (ylim[1] - ylim[0]) * inset_size_frac), + 'lower left': (xlim[0], xlim[0] + (xlim[1] - xlim[0]) * inset_size_frac, + ylim[0], ylim[0] + (ylim[1] - ylim[0]) * inset_size_frac), + 'lower center': (xlim[0] + (xlim[1] - xlim[0]) * (0.5 - inset_size_frac/2), + xlim[0] + (xlim[1] - xlim[0]) * (0.5 + inset_size_frac/2), + ylim[0], ylim[0] + (ylim[1] - ylim[0]) * inset_size_frac), + 'center right': (xlim[1] - (xlim[1] - xlim[0]) * inset_size_frac, xlim[1], + ylim[0] + (ylim[1] - ylim[0]) * 0.3, ylim[0] + (ylim[1] - ylim[0]) * 0.7), + 'center left': (xlim[0], xlim[0] + (xlim[1] - xlim[0]) * inset_size_frac, + ylim[0] + (ylim[1] - ylim[0]) * 0.3, ylim[0] + (ylim[1] - ylim[0]) * 0.7), + } + + # Count points in each potential inset region + point_counts = {} + for location, (x_min, x_max, y_min, y_max) in positions.items(): + # Count points that fall within this region + in_region = ((x_data >= x_min) & (x_data <= x_max) & + (y_data >= y_min) & (y_data <= y_max)) + point_counts[location] = np.sum(in_region) + + # Find location with minimum point density + best_location = min(point_counts, key=point_counts.get) + + # If all positions have similar point counts, prefer corner positions for aesthetics + min_count = min(point_counts.values()) + corner_positions = ['upper right', 'upper left', 'lower right', 'lower left'] + + # If multiple positions have the same minimal count, prefer corners + minimal_positions = [loc for loc, count in point_counts.items() if count == min_count] + corner_minimal = [loc for loc in minimal_positions if loc in corner_positions] + + if corner_minimal: + # Among corners with minimal points, prefer upper right as default + if 'upper right' in corner_minimal: + return 'upper right' + else: + return corner_minimal[0] + else: + return best_location + + +def formatOceanCompositionLabel(comp): + """ + Format ocean composition labels for display. + + Args: + comp: Composition string + + Returns: + str: Formatted label + """ + if 'CustomSolution' in comp: + return comp.split('=')[0].replace('CustomSolution', '').strip() + else: + return comp + + +def extract_magnetic_field_data(result_obj, field_name): + + excitation_name = result_obj.excName + inductionFieldName = field_name.replace('Induction', '') + calcedExc = result_obj.induction.calcedExc + iExcName = calcedExc.index(excitation_name) + try: + fullMagneticInductionData = getattr(result_obj.induction, inductionFieldName) + except: + raise ValueError(f"Field {inductionFieldName} not found in result.induction") + excExcitationData = fullMagneticInductionData[iExcName, :, :] + return excExcitationData + diff --git a/PlanetProfile/Plotting/ExplorationPlots.py b/PlanetProfile/Plotting/ExplorationPlots.py new file mode 100644 index 00000000..1c8f889d --- /dev/null +++ b/PlanetProfile/Plotting/ExplorationPlots.py @@ -0,0 +1,1373 @@ +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.gridspec import GridSpec +from matplotlib.colors import Normalize +from matplotlib.cm import get_cmap +from matplotlib.lines import Line2D +import matplotlib.ticker as ticker +from PlanetProfile.GetConfig import Color, Style, FigLbl, FigSize, FigMisc +from PlanetProfile.Plotting.EssentialHelpers import * +from PlanetProfile.Utilities.DataManip import smoothGrid +from PlanetProfile.Thermodynamics.Reaktoro.CustomSolution import SetupCustomSolutionPlotSettings +from scipy.interpolate import griddata +import logging +log = logging.getLogger('PlanetProfile') + +def GenerateExplorationPlots(ExplorationList, FigureFilesList, Params): + """ + Generate all exploration plots for a given list of results. + """ + PLOT_EXPLORATION = not (Params.Explore.nx == 1 or Params.Explore.ny == 1) # If either axis is of size 1, then we cannot plot + # Setup CustomSolution plot settings + all_ocean_comps = [] + for Exploration in ExplorationList: + all_ocean_comps.extend(np.array(Exploration.base.oceanComp).flatten()) + Params = SetupCustomSolutionPlotSettings(np.array(all_ocean_comps), Params) + if PLOT_EXPLORATION and not Params.SKIP_PLOTS: + # Use multi-subplot function only for multiple z-variables + if isinstance(Params.Explore.zName, list) and Params.PLOT_COMBO_EXPLORATIONS: + PlotExploreOgramMultiSubplot(ExplorationList, FigureFilesList, Params) + else: + if isinstance(Params.Explore.zName, list): + originalZName = Params.Explore.zName + zNamesList = Params.Explore.zName + originalFigName = Params.FigureFiles.explore + figNames = Params.FigureFiles.explore + else: + originalZName = Params.Explore.zName + zNamesList = [Params.Explore.zName] + originalFigName = Params.FigureFiles.explore + figNames = [Params.FigureFiles.explore] + for zName, figName in zip(zNamesList, figNames): + Params.Explore.zName = zName + Params.FigureFiles.explore = figName + # Use original single-plot function for single z-variable + for Exploration in ExplorationList: + Exploration.zName = zName + PlotExploreOgram(ExplorationList, FigureFilesList, Params) + Params.Explore.zName = originalZName + Params.FigureFiles.explore = originalFigName + # Now we plot the ZbD plots (must plot after exploreogram plots since we change the x and y variables) + if Params.PLOT_Zb_D: + if isinstance(Params.Explore.zName, list): + figNames = Params.FigureFiles.exploreZbD + [] + for zName, figName in zip(Params.Explore.zName, figNames): + for Exploration in ExplorationList: + Exploration.zName = zName + Params.FigureFiles.exploreZbD = figName + PlotExploreOgramZbD(ExplorationList, FigureFilesList, Params) + else: + for Exploration in ExplorationList: + Exploration.zName = Params.Explore.zNameZbD + PlotExploreOgramZbD(ExplorationList, FigureFilesList, Params) + if Params.PLOT_D_SIGMA: + PlotExploreOgramDsigma(ExplorationList, FigureFilesList, Params) + if Params.PLOT_LOVE_COMPARISON: + PlotExploreOgramLoveComparison(ExplorationList, FigureFilesList, Params) + + +def PlotExploreOgramDsigma(results_list, FigureFilesList, Params): + # Step 1: Configure FigLbl using existing system (original pattern) + results_list[0].xName = 'D_km' + results_list[0].yName = 'sigmaMean_Sm' + results_list[0].zName = 'zb_km' + + FigLbl.SetExploration(results_list[0].bodyname, results_list[0].xName, + results_list[0].yName, results_list[0].zName) + if not FigMisc.TEX_INSTALLED: + FigLbl.StripLatex() + + # Step 2: Create plots for each result (individual + comparison if present) + for i, result in enumerate(r for r in results_list if not r.base.NO_H2O): + FigureFiles = FigureFilesList[i] + Params.FigureFiles = FigureFiles + xName = 'D_km' + yName = 'sigmaMean_Sm' + result.zName = 'zb_km' + + # Determine if this is the comparison plot (last result when COMPARE is enabled) + is_comparison_plot = (Params.COMPARE and i == len([r for r in results_list if not r.base.NO_H2O]) - 1 + and len(results_list) > 1) + + # Extract and validate data using helper + plot_data = extract_and_validate_plot_data(result_obj = result, x_field = xName, y_field = yName, c_field = result.zName, + x_multiplier = FigLbl.xMultExplore, y_multiplier = FigLbl.yMultExplore, c_multiplier = 1.0, contour_multiplier = 1.0, + custom_x_axis = FigLbl.xCustomAxis, custom_y_axis = FigLbl.yCustomAxis) + + if len(plot_data['x']) == 0: + log.warning(f"No valid data points for {result.bodyname}") + continue + + # Create figure and axis (original pattern) + fig = plt.figure(figsize=FigSize.explore) + grid = GridSpec(1, 1) + ax = fig.add_subplot(grid[0, 0]) + if Style.GRIDS: + ax.grid() + ax.set_axisbelow(True) + + # Set up axis properties using FigLbl + D/sigma specific overrides + if Params.TITLES: + # Use comparison title for comparison plots, regular title for individual plots + if is_comparison_plot: + fig.suptitle(FigLbl.exploreCompareTitle) # Comparison-specific title + else: + fig.suptitle(FigLbl.explorationDsigmaTitle) # Individual plot title + ax.set_xlabel(FigLbl.xLabelExplore) + ax.set_ylabel(FigLbl.yLabelExplore) + # D/sigma specific settings (override standard) + ax.set_xscale('linear') + ax.set_yscale('log') + ax.set_ylim(FigMisc.DSIGMA_YLIMS) + ax.set_xlim([np.nanmin(plot_data['x']), np.nanmax(plot_data['x'])]) + + # Get ocean composition data for composition lines and legends + ocean_comp = result.base.oceanComp.flatten() + + # Draw composition lines if enabled (using helper for 20+ line block) + plotted_labels = set() + if FigMisc.DRAW_COMPOSITION_LINE: + plotted_labels = draw_ocean_composition_lines( + ax, plot_data['x'], plot_data['y'], plot_data['c'], ocean_comp, + use_manual_colors=FigMisc.MANUAL_HYDRO_COLORS, + line_width=FigMisc.DSIGMA_COMP_LINE_WIDTH, + line_alpha=FigMisc.DSIGMA_COMP_LINE_ALPHA + ) + + # Create scatter plot with clear if/else for different modes (original logic) + if FigMisc.SHOW_ICE_THICKNESS_DOTS: + # Ice thickness coloring mode + pts = ax.scatter(plot_data['x'], plot_data['y'], c=plot_data['c'], + cmap=FigMisc.DSIGMA_ICE_THICKNESS_CMAP, + marker=Style.MS_Induction, s=Style.MW_Induction**2, + edgecolors=FigMisc.DSIGMA_DOT_EDGE_COLOR, + linewidths=FigMisc.DSIGMA_DOT_EDGE_WIDTH, + zorder=3) + + # Create ice thickness legend using helper (15+ line block) + ice_legend = create_ice_thickness_colorbar( + ax, plot_data['c'], + cmap_name=FigMisc.DSIGMA_ICE_THICKNESS_CMAP, + ) + + else: + # Standard colorbar mode + # Get first ocean composition for colormap selection + first_comp = ocean_comp[0] if len(ocean_comp) > 0 else 'default' + cmap_to_use = Color.cmap.get(first_comp, 'default') + + pts = ax.scatter(plot_data['x'], plot_data['y'], c=plot_data['c'], + cmap=cmap_to_use, marker=Style.MS_Induction, + s=Style.MW_Induction**2, zorder=3) + + # Add colorbar (original logic) + cbar = fig.colorbar(pts, ax=ax, format=FigLbl.cbarFmt) + if plot_data['c'] is not None and len(plot_data['c']) > 0: + # Append min and max values to colorbar for reading convenience + new_ticks = np.insert(np.append(cbar.get_ticks(), np.max(plot_data['c'])), 0, np.min(plot_data['c'])) + cbar.set_ticks(np.unique(new_ticks)) + cbar.set_label(FigLbl.cbarLabelExplore, size=12) + + # Add composition legend if enabled (using helper) + if FigMisc.DRAW_COMPOSITION_LINE and Params.LEGEND: + add_composition_legend(ax, ocean_comp, + show_ice_thickness_legend=FigMisc.SHOW_ICE_THICKNESS_DOTS, + font_size=FigMisc.DSIGMA_COMP_LEGEND_FONT_SIZE) + + # Save figure (original pattern) + plt.tight_layout() + fig.savefig(Params.FigureFiles.exploreDsigma, format=FigMisc.figFormat, + dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) + log.debug(f'Plot saved to file: {Params.FigureFiles.exploreDsigma}') + plt.close() + + +def PlotExploreOgram(ExplorationList, FigureFilesList,Params, ax=None): + # Set up basic figure labels using first result + first_result = ExplorationList[0] + FigLbl.SetExploration(first_result.bodyname, first_result.xName, + first_result.yName, first_result.zName, Params.Explore.contourName, first_result.excName, FigLbl.titleAddendum) + if not FigMisc.TEX_INSTALLED: + FigLbl.StripLatex() + # Handle axis creation - support for multi-subplot usage + createNewFigure = ax is None + + DO_SMOOTHING = FigMisc.EXPLOREOGRAM_SMOOTHING + for i, exploration in enumerate(ExplorationList): + FigureFiles = FigureFilesList[i] + Params.FigureFiles = FigureFiles + + if createNewFigure: + # Create new figure/axes and save it (original behavior) + fig = plt.figure(figsize=FigSize.explore) + grid = GridSpec(1, 1) + ax = fig.add_subplot(grid[0, 0]) + elif isinstance(ax, list): + ax = ax[i] + fig = ax.figure # Get figure from provided axis + else: + fig = ax.figure # Get figure from provided axis + + if Style.GRIDS: + ax.grid() + ax.set_axisbelow(True) + else: + ax.grid(False) + ax.set_axisbelow(False) + + # Only set figure title if we created the figure (not in subplot mode) + if Params.TITLES and createNewFigure: + fig.suptitle(FigLbl.explorationTitle) + elif Params.TITLES and not createNewFigure: + # In subplot mode, set the subplot title instead of figure title + ax.set_title(FigLbl.cbarLabelExplore) + + # Set up axis labels and scales + ax.set_xlabel(FigLbl.xLabelExplore) + ax.set_ylabel(FigLbl.yLabelExplore) + ax.set_xscale(FigLbl.xScaleExplore) + ax.set_yscale(FigLbl.yScaleExplore) + + # Extract x, y, z, and contour data using enhanced helper function + contour_field = Params.Explore.contourName + + plot_data = extract_and_validate_plot_data(exploration, exploration.xName, exploration.yName, exploration.zName, contour_field, + x_multiplier=FigLbl.xMultExplore, y_multiplier=FigLbl.yMultExplore, c_multiplier=FigLbl.zMultExplore, + contour_multiplier=1.0, custom_x_axis = FigLbl.xCustomAxis, custom_y_axis = FigLbl.yCustomAxis) + original_shape = plot_data['original_shape'] + x = plot_data['x'].reshape(original_shape) + y = plot_data['y'].reshape(original_shape) + z = plot_data['c'].reshape(original_shape) + contourData = z if contour_field is None else plot_data['contour'].reshape(original_shape) + + + # Set axis limits + ax.set_xlim([np.min(x), np.max(x)]) + ax.set_ylim([np.min(y), np.max(y)]) + + # Only keep data points for which a valid model was determined + z_shape = np.shape(z) + z = np.reshape(z, -1).astype(np.float64) + INVALID = np.logical_not(np.reshape(exploration.base.VALID, -1)) + z[INVALID] = np.nan + # Return data to original organization + z = np.reshape(z, z_shape) + + if DO_SMOOTHING: + smoothFactor = FigMisc.EXPLOREOGRAM_SMOOTHING_FACTOR + x, y, [z, contourData] = smoothGrid(x, y, [z, contourData], smoothFactor) + # Calculate valid data range for colorbar + z_valid = z[z == z] # Exclude NaNs + + if np.size(z_valid) > 0: + + data_min = np.min(z_valid) + data_max = np.max(z_valid) + # Check if we should pin colormap center to zero + if (data_min is not None and data_max is not None and + exploration.zName in FigLbl.cMapZero and + data_min < 0 and data_max > 0): + # Pin colormap center to zero for variables that can be positive/negative + abs_max = max(abs(data_min), abs(data_max)) + vmin = -abs_max + vmax = abs_max + else: + # Use actual data range for colormap + vmin = data_min + vmax = data_max + # Create the main plot + mesh = ax.pcolormesh(x, y, z, shading='auto', cmap=Color.cmap['default'], + vmin=vmin, vmax=vmax, rasterized=FigMisc.PT_RASTER, edgecolors='none', linewidth = 0) + + """Colorbar""" + # Add colorbar + cbar = fig.colorbar(mesh, ax=ax, format=FigLbl.cbarFmt) + # Change tick spacing if specified + if FigLbl.cTicksSpacingExplore is not None: + cbar_ticks = np.arange(np.ceil(data_min / FigLbl.cTicksSpacingExplore) * FigLbl.cTicksSpacingExplore, np.floor(data_max / FigLbl.cTicksSpacingExplore) * FigLbl.cTicksSpacingExplore + 0.0001, FigLbl.cTicksSpacingExplore) + + if len(cbar_ticks) > 8: + tooManyTicks = True + cTicksSpacingExplore = FigLbl.cTicksSpacingExplore + while tooManyTicks: + cTicksSpacingExplore = cTicksSpacingExplore + FigLbl.cTicksSpacingExplore + cbar_ticks = np.arange(np.ceil(data_min / cTicksSpacingExplore) * cTicksSpacingExplore, np.floor(data_max / cTicksSpacingExplore) * cTicksSpacingExplore + 0.0001, cTicksSpacingExplore) + if len(cbar_ticks) <= 8: + tooManyTicks = False + cbar.set_ticks(cbar_ticks) + # Use the adjusted vmin/vmax that may have been zero-pinned + mesh.set_clim(vmin=vmin, vmax=vmax) + + # Check if we should truncate the colorbar (only for zero-pinned colormaps) + if (exploration.zName in FigLbl.cMapZero and + data_min is not None and data_max is not None and + data_min < 0 and data_max > 0): + # Truncate colorbar to only show the portion with actual data + # Calculate the fraction of the colormap that corresponds to actual data + colormap_range = vmax - vmin + data_range = data_max - data_min + + # Calculate the position of data within the full colormap + data_start_fraction = (data_min - vmin) / colormap_range + data_end_fraction = (data_max - vmin) / colormap_range + existing_ticks = cbar.get_ticks() + # Truncate the colorbar + cbar.ax.set_ylim(data_start_fraction, data_end_fraction) + + valid_ticks = existing_ticks[(existing_ticks >= data_min) & (existing_ticks <= data_max)] + # Add min and max values to the filtered ticks (using actual data range) + new_ticks = np.insert(np.append(valid_ticks, data_max), 0, data_min) + cbar.set_ticks(np.unique(new_ticks)) + else: + # Normal behavior - filter ticks but don't truncate colorbar + existing_ticks = cbar.get_ticks() + if len(existing_ticks) > 1: + tick_diff = existing_ticks[1] - existing_ticks[0] + valid_ticks = existing_ticks[(existing_ticks >= data_min) & (existing_ticks <= data_max)] + if valid_ticks.size > 1: + if valid_ticks[0] - data_min < tick_diff * 0.2: + valid_ticks = np.delete(valid_ticks, 0) + if data_max - valid_ticks[-1] < tick_diff * 0.2: + valid_ticks = np.delete(valid_ticks, -1) + else: + valid_ticks = existing_ticks + elif len(existing_ticks) == 1: + tick_diff = data_max - data_min + if existing_ticks[0] - data_min < tick_diff * 0.2: + valid_ticks = np.delete(existing_ticks, 0) + elif data_max - existing_ticks[0] < tick_diff * 0.2: + valid_ticks = np.delete(existing_ticks, 0) + else: + valid_ticks = existing_ticks + else: + valid_ticks = existing_ticks + # Add min and max values to the filtered ticks (using actual data range) + new_ticks = np.insert(np.append(valid_ticks, data_max), 0, data_min) + cbar.set_ticks(np.unique(new_ticks)) + + cbar.ax.tick_params(labelsize=17) + if createNewFigure: + cbar.set_label(FigLbl.cbarLabelExplore, size=25) + + + """Contour levels""" + # If no contour field is provided, then we use the color field (i.e. z variable) + if contour_field is None: + # Get colorbar tick values to use as contour levels + cbar_ticks = cbar.get_ticks() + # Ensure contour levels are within the data range + contourLevels = cbar_ticks[(cbar_ticks > vmin) & (cbar_ticks < vmax)] + else: + # Apply same validity mask to contour variable + contourShape = np.shape(contourData) + contourData = np.reshape(contourData, -1).astype(np.float64) + contourData[INVALID] = np.nan + contourData = np.reshape(contourData, contourShape) + # Calculate valid data range for colorbar + contourValid = contourData[contourData == contourData] # Exclude NaNs + + if np.size(contourValid) > 0: + contourLevels = np.linspace(np.min(contourValid), np.max(contourValid), 5) + else: + contourLevels = np.array([]) + # Change contour spacing if specified + if FigLbl.cSpacingExplore is not None and contourLevels.size > 0: + contourMin = np.min(contourLevels) + contourMax = np.max(contourLevels) + # Ensure contour levels are within the data range + contourLevels = np.arange(np.ceil(contourLevels[0] / FigLbl.cSpacingExplore) * FigLbl.cSpacingExplore, np.floor(contourLevels[-1] / FigLbl.cSpacingExplore) * FigLbl.cSpacingExplore + 0.0001, FigLbl.cSpacingExplore) + if len(contourLevels) > 5: + tooManyContours = True + cSpacingExplore = FigLbl.cSpacingExplore + while tooManyContours: + cSpacingExplore *= 2 + contourLevels = np.arange(np.ceil(contourMin / cSpacingExplore) * cSpacingExplore, np.floor(contourMax / cSpacingExplore) * cSpacingExplore + 0.0001, cSpacingExplore) + if len(contourLevels) <= 5: + tooManyContours = False + + # Reduce contour text size if they are close to other contours + if len(contourLevels) > 0: + cont = ax.contour(x, y, contourData, levels=contourLevels, colors='black') + lbls = plt.clabel(cont, fmt=FigLbl.cfmt, fontsize=15, + colors='black', inline=True) + # Get pixel positions of all labels + positions = np.array([ax.transData.transform(txt.get_position()) for txt in lbls]) + n = len(lbls) + + close_to_another = np.zeros(n, dtype=bool) + + for i in range(n): + for j in range(i+1, n): + dist = np.linalg.norm(positions[i] - positions[j]) + if dist < 25: + close_to_another[i] = True + close_to_another[j] = True + + for i, txt in enumerate(lbls): + if close_to_another[i]: + txt.set_fontsize(2) + + # Save the plot only if we created a new figure + if createNewFigure: + plt.tight_layout() + fig.savefig(Params.FigureFiles.explore, format=FigMisc.figFormat, + dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) + log.debug(f'Plot saved to file: {Params.FigureFiles.explore}') + plt.close() + # Return the axis for multi-subplot usage (only if ax was provided) + else: + return ax + + +def PlotExploreOgramMultiSubplot(results_list, FigureFilesList, Params): + """ + Create multiple exploration subplots with different z-variables in a single figure. + + This function should only be called when Params.Explore.zName is a list with + multiple z-variables. It arranges them in a square-ish grid layout, calls + PlotExploreOgram for each z-variable, and lets that function handle + individual subplot titles. Only adds an overall figure title at the top. + + Args: + results_list: List of ExplorationResults objects + Params: Configuration parameters (Params.Explore.zName must be a list) + """ + + # Params.Explore.zName should be a list when this function is called + zNames = [] + zExcNames = [] + n_subplots = 0 + for i, z_name in enumerate(Params.Explore.zName): + if z_name in FigLbl.zNamePlotRealImag: + real_name = FigLbl.zNamePlotRealImag[z_name][0] + imag_name = FigLbl.zNamePlotRealImag[z_name][1] + nExc_subplot, excNames = countPlottableExcitations(results_list[0].induction.calcedExc, Params.Induct) + for excName in excNames: + zNames.append(real_name) + zExcNames.append(excName) + zNames.append(imag_name) + zExcNames.append(excName) + n_subplots += 2 + elif 'Induction' in z_name: + nExc_subplot, excNames = countPlottableExcitations(results_list[0].induction.calcedExc, Params.Induct) + n_subplots += nExc_subplot + for excName in excNames: + zNames.append(z_name) + zExcNames.append(excName) + else: + n_subplots += 1 + zNames.append(z_name) + zExcNames.append(None) + + n_cols = int(np.ceil(np.sqrt(n_subplots))) + n_rows = int(np.ceil(n_subplots / n_cols)) + + # Calculate figure size with scaling + base_size = FigSize.explore + scale_factor = 1 + fig_width = base_size[0] * n_cols * scale_factor + fig_height = base_size[1] * n_rows * scale_factor + + + + for j, Exploration in enumerate(results_list): + # Create figure with subplots + fig, axes = plt.subplots(n_rows, n_cols, figsize=(fig_width, fig_height), constrained_layout=True, squeeze=False) + SubListFigureFiles = [FigureFilesList[j]] + # Create subplots and let PlotExploreOgram handle individual plots + for i in range(n_subplots): + row = i // n_cols + col = i % n_cols + ax = axes[row, col] + + # Set z-variable for all results + Exploration.zName = zNames[i] + Exploration.excName = zExcNames[i] + + # Call PlotExploreOgram with this axis - let it handle the subplot title + PlotExploreOgram([Exploration], SubListFigureFiles, Params, ax=ax) + # Explicitly disable grid after plotting to prevent grid lines from appearing on save + if not Style.GRIDS: + ax.grid(False) + ax.set_axisbelow(False) + # Add subplot label (a, b, c, etc.) if enabled + if FigMisc.SUBPLOT_LABELS: + letters = "abcdefghijklmnopqrstuvwxyz" + label = "" + n = i + 1 + while n: + n, r = divmod(n - 1, 26) + label = letters[r] + label + + ax.text( + FigMisc.SUBPLOT_LABEL_X, FigMisc.SUBPLOT_LABEL_Y, label, + transform=ax.transAxes, + fontsize=FigMisc.SUBPLOT_LABEL_FONTSIZE, + fontweight='bold', + ha='left', + va='top', + bbox=dict(boxstyle='round,pad=0.3', facecolor='white', alpha=0.8) + ) + + # After plotting, hide axis labels selectively + is_bottom_row = (row == n_rows - 1) or (i >= n_subplots - n_cols) + is_left_column = (col == 0) + + if not is_bottom_row: + ax.set_xlabel('') + ax.tick_params(axis='x', labelbottom=False) + ax.tick_params(axis='x', labelsize=FigMisc.SUBPLOT_LABEL_FONTSIZE) + if not is_left_column: + ax.set_ylabel('') + ax.tick_params(axis='y', labelleft=False, labelsize=FigMisc.SUBPLOT_LABEL_FONTSIZE) + + # Hide unused subplots + total_subplots = n_rows * n_cols + for i in range(n_subplots, total_subplots): + ax = fig.add_subplot(n_rows, n_cols, i + 1) + ax.set_visible(False) + # Set overall title if configured + fig.suptitle(FigLbl.subplotExplorationTitle, fontsize=15) + # Explicitly disable grid after plotting to prevent grid lines from appearing on save + for ax in axes.flatten(): + if not Style.GRIDS: + ax.grid(False) + ax.set_axisbelow(False) + + fig.savefig(Params.FigureFiles.exploreMultiSubplot, format=FigMisc.figFormat, + dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) + log.debug(f'Multi-subplot exploration plot sved to file: {Params.FigureFiles.exploreMultiSubplot}') + plt.close() + + +def PlotExploreOgramZbD(results_list, FigureFilesList, Params): + # Set up basic figure labels using first result + first_result = results_list[0] + FigLbl.SetExploration(first_result.bodyname, 'zb_km', 'D_km', first_result.zName) + if not FigMisc.TEX_INSTALLED: + FigLbl.StripLatex() + + # Plot each result (individual exploration results + optional comparison) + last_index = len(results_list) - 1 + for i, result in enumerate(results_list): + FigureFiles = FigureFilesList[i] + Params.FigureFiles = FigureFiles + # Skip results with no H2O + if result.base.NO_H2O: + continue + + # Set axis variable names for this plot type + _, xName = getIceShellThickness(result) + yName = 'D_km' + # zName should already be set by user + + # Detect if this is a comparison plot (last result when COMPARE=True) + is_comparison_plot = Params.COMPARE and i == last_index + + # Extract and validate data using helper + plot_data = extract_and_validate_plot_data(result_obj = result, x_field = xName, y_field = yName, c_field = result.zName, + x_multiplier = FigLbl.xMultExplore, y_multiplier = FigLbl.yMultExplore, c_multiplier = FigLbl.zMultExplore, + custom_x_axis = FigLbl.xCustomAxis, custom_y_axis = FigLbl.yCustomAxis) + + if len(plot_data['x']) == 0: + log.warning(f"No valid data points for {result.bodyname}") + continue + + # Create figure and axis + fig = plt.figure(figsize=FigSize.explore) + grid = GridSpec(1, 1) + ax = fig.add_subplot(grid[0, 0]) + if Style.GRIDS: + ax.grid() + ax.set_axisbelow(True) + + # Set title based on plot type + if Params.TITLES: + if is_comparison_plot: + fig.suptitle(FigLbl.exploreCompareTitle) + else: + fig.suptitle(FigLbl.explorationTitle) + + # Set up axis labels and scales + ax.set_xlabel(FigLbl.xLabelExplore) + ax.set_ylabel(FigLbl.yLabelExplore) + ax.set_xscale('linear') # ZbD plots use linear scales + ax.set_yscale('linear') + + # Extract plot data + x, y, z = plot_data['x'], plot_data['y'], plot_data['c'] + + # Set axis limits with padding + if np.size(x) > 0: + x_range = np.nanmax(x) - np.nanmin(x) + y_range = np.nanmax(y) - np.nanmin(y) + x_padding = x_range * FigMisc.ZBD_AXIS_PADDING + y_padding = y_range * FigMisc.ZBD_AXIS_PADDING + ax.set_xlim([np.nanmin(x) - x_padding, np.nanmax(x) + x_padding]) + ax.set_ylim([np.nanmin(y) - y_padding, np.nanmax(y) + y_padding]) + + # Get ocean composition data for composition lines + ocean_comp = result.base.oceanComp.flatten() + + + # Separate NaN and non-NaN points for different plotting + nan_mask = np.isnan(z) + valid_mask = ~nan_mask + + # Plot non-NaN points colored by z variable + if np.any(valid_mask): + z_valid = z[valid_mask] + pts = ax.scatter(x[valid_mask], y[valid_mask], c=z_valid, + cmap=FigMisc.ZBD_COLORMAP, marker=Style.MS_Induction, + s=Style.MW_Induction**2, edgecolors=FigMisc.ZBD_DOT_EDGE_COLOR, + linewidths=FigMisc.ZBD_DOT_EDGE_WIDTH, zorder=3, + vmin=np.nanmin(z_valid), vmax=np.nanmax(z_valid)) + # Draw composition lines if enabled using helper + if FigMisc.DRAW_COMPOSITION_LINE: + draw_ocean_composition_lines(ax, x, y, z, ocean_comp, + FigMisc.MANUAL_HYDRO_COLORS, + FigMisc.ZBD_COMP_LINE_WIDTH, FigMisc.ZBD_COMP_LINE_ALPHA) + + # Add colorbar + cbar = fig.colorbar(pts, ax=ax, format=FigLbl.cbarFmt) + # Extend colorbar to cover the full range of default ticks + default_ticks = cbar.get_ticks() + if len(default_ticks) > 0: + pts.set_clim(vmin=np.min(default_ticks), vmax=np.max(default_ticks)) + cbar.set_label(FigLbl.cbarLabelExplore, size=12) + + # Plot NaN points with distinct color + if np.any(nan_mask): + ax.scatter(x[nan_mask], y[nan_mask], c=FigMisc.ZBD_NAN_COLOR, + marker=FigMisc.ZBD_NAN_MARKER, s=Style.MW_Induction**2, + linewidths=FigMisc.ZBD_DOT_EDGE_WIDTH, + zorder=3) + + # Add legends if enabled + if FigMisc.DRAW_COMPOSITION_LINE and Params.LEGEND: + ax.legend(fontsize=FigMisc.ZBD_COMP_LEGEND_FONT_SIZE) + # Save the plot + plt.tight_layout() + fig.savefig(Params.FigureFiles.exploreZbD, format=FigMisc.figFormat, + dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) + log.debug(f'Plot saved to file: {Params.FigureFiles.exploreZbD}') + plt.close() + + +def PlotExploreOgramLoveComparison(results_list, FigureFilesList, Params): + """ + + Plots scatter showing tidal love number k2 versus delta (1+k2-h2) for comparison + against canonical k2/delta exploration plots. Features ice thickness coloring, + composition lines, dual legend system, and optional error bars. + + Args: + results_list: List of ExplorationResults objects (individual + optional comparison) + Params: Configuration parameters + """ + + # Set up basic figure labels using first result + first_result = results_list[0] + FigLbl.SetExploration(first_result.bodyname, 'deltaLoveAmp', + 'kLoveAmp', 'oceanComp') + if not FigMisc.TEX_INSTALLED: + FigLbl.StripLatex() + + # Plot each result (individual exploration results + optional comparison) + last_index = len(results_list) - 1 + for i, result in enumerate(results_list): + FigureFiles = FigureFilesList[i] + Params.FigureFiles = FigureFiles + # Skip results with no H2O + if result.base.NO_H2O: + continue + + # Set axis variable names for this plot type + xName = 'deltaLoveAmp' + yName = 'kLoveAmp' + result.zName = 'oceanComp' + + # Detect if this is a comparison plot (last result when COMPARE=True) + is_comparison_plot = Params.COMPARE and i == last_index + + # Extract and validate data using helper + plot_data = extract_and_validate_plot_data(result_obj = result, x_field = xName, y_field = yName, c_field = result.zName, + x_multiplier = FigLbl.xMultExplore, y_multiplier = FigLbl.yMultExplore, c_multiplier = FigLbl.zMultExplore, + custom_x_axis = FigLbl.xCustomAxis, custom_y_axis = FigLbl.yCustomAxis) + + if np.all(np.isnan(plot_data['x'])) or np.all(np.isnan(plot_data['y'])): + log.warning(f'Love numbers not calculated') + + # Create figure and axis + fig = plt.figure(figsize=FigSize.explore) + grid = GridSpec(1, 1) + ax = fig.add_subplot(grid[0, 0]) + if Style.GRIDS: + ax.grid() + ax.set_axisbelow(True) + + # Set title based on plot type + if Params.TITLES: + if is_comparison_plot: + fig.suptitle(FigLbl.exploreCompareTitle) + else: + fig.suptitle(FigLbl.explorationLoveComparisonTitle) + + # Set up axis labels and scales + ax.set_xlabel(FigLbl.xLabelExplore) + ax.set_ylabel(FigLbl.yLabelExplore) + ax.set_xscale('linear') # Love plots use linear scales + ax.set_yscale('linear') + + # Extract plot data + x, y, z = plot_data['x'], plot_data['y'], plot_data['c'] + + # Get ice thickness data for coloring + ice_thickness_data = extract_and_validate_plot_data(result_obj = result, x_field = 'zb_approximate_km', y_field = yName, + c_field = None, contour_field = None, + x_multiplier = 1.0, y_multiplier = 1.0, c_multiplier = 1.0, contour_multiplier = 1.0, + custom_x_axis = FigLbl.xCustomAxis, custom_y_axis = FigLbl.yCustomAxis) + ice_thickness = ice_thickness_data['x'] # x field contains the ice thickness + + # Get ocean composition data for composition lines + ocean_comp = result.base.oceanComp.flatten() + + # Draw composition lines if enabled using helper + if FigMisc.DRAW_COMPOSITION_LINE: + # Get temperature data for color scaling + temp_data = extract_and_validate_plot_data(result_obj = result, x_field = 'Tb_K', y_field = yName, c_field = None, contour_field = None, + x_multiplier = 1.0, y_multiplier = 1.0, c_multiplier = 1.0, contour_multiplier = 1.0, + custom_x_axis = FigLbl.xCustomAxis, custom_y_axis = FigLbl.yCustomAxis) + temp_values = temp_data['x'] # Temperature values for color scaling + + # Set up temperature bounds for color mapping + if len(temp_values) > 0: + TminMax_K = [np.nanmin(temp_values), np.nanmax(temp_values)] + Color.Tbounds_K = TminMax_K + + draw_ocean_composition_lines(ax, x, y, temp_values, ocean_comp, + FigMisc.MANUAL_HYDRO_COLORS, + FigMisc.LOVE_COMP_LINE_WIDTH, FigMisc.LOVE_COMP_LINE_ALPHA) + + # Add error bars if enabled + if FigMisc.SHOW_ERROR_BARS: + ax.errorbar(x, y, yerr=FigMisc.ERROR_BAR_MAGNITUDE, fmt='none', + color='gray', capsize=3, alpha=0.5) + + # Handle ice thickness coloring + if len(ice_thickness) > 0: + # Set bounds for normalization + Tbound_lower = np.min(ice_thickness) + Tbound_upper = np.max(ice_thickness) + # Get normalized values using GetNormT + norm_thickness = Color.GetNormT(ice_thickness, Tbound_lower, Tbound_upper) + else: + norm_thickness = np.ones(np.shape(x)) + + # Plot scatter points with ice thickness coloring and optional convection-based markers + if FigMisc.SHOW_CONVECTION_WITH_SHAPE: + # Plot with different markers for convection vs non-convection + # Convection points (Dconv_m > 0): use circle marker + # Non-convection points (Dconv_m <= 0): use square marker + convection_data = extract_and_validate_plot_data(result_obj = result, x_field = xName, y_field = yName, + c_field = 'Dconv_m', contour_field = None, + x_multiplier = 1.0, y_multiplier = 1.0, c_multiplier = 1.0, contour_multiplier = 1.0, + custom_x_axis = FigLbl.xCustomAxis, custom_y_axis = FigLbl.yCustomAxis) + # Create boolean array: True for convection (Dconv_m > 0), False for no convection + convection_markers = convection_data['c'] > 0 + conv_mask = convection_markers + non_conv_mask = ~convection_markers + # 2. Create a Normalize object to map your data range to [0, 1] + norm = Normalize(vmin=norm_thickness.min(), vmax=norm_thickness.max()) + + # 3. Apply the colormap to normalized data to get RGBA values + colors = get_cmap(FigMisc.LOVE_ICE_THICKNESS_CMAP)(norm(norm_thickness)) + + + # Plot convection points + if np.any(conv_mask): + ax.scatter(x[conv_mask], y[conv_mask], c=colors[conv_mask], marker='v', + s=Style.MW_Induction**2, edgecolors=FigMisc.LOVE_DOT_EDGE_COLOR, + linewidths=FigMisc.LOVE_DOT_EDGE_WIDTH, zorder=3) + + # Plot non-convection points + if np.any(non_conv_mask): + ax.scatter(x[non_conv_mask], y[non_conv_mask], c=colors[non_conv_mask], marker='^', + s=Style.MW_Induction**2, edgecolors=FigMisc.LOVE_DOT_EDGE_COLOR, + linewidths=FigMisc.LOVE_DOT_EDGE_WIDTH, zorder=3) + else: + # Plot with default marker + pts = ax.scatter(x, y, c=norm_thickness, + cmap=FigMisc.LOVE_ICE_THICKNESS_CMAP, marker=Style.MS_Induction, + s=Style.MW_Induction**2, edgecolors=FigMisc.LOVE_DOT_EDGE_COLOR, + linewidths=FigMisc.LOVE_DOT_EDGE_WIDTH, zorder=3) + + # Create ice thickness legend if enabled + if FigMisc.SHOW_ICE_THICKNESS_DOTS and len(ice_thickness) > 0 and Params.LEGEND: + ice_legend = create_ice_thickness_colorbar( + ax, ice_thickness, + cmap_name=FigMisc.LOVE_ICE_THICKNESS_CMAP, + ) + + # Set axis limits with special Love plot formatting + if np.size(x) > 0: + ax.set_xlim([0, np.nanmax(x) + 0.01]) + if np.size(y) > 0: + # Account for error bars when setting y limits + if FigMisc.SHOW_ERROR_BARS: + error_magnitude = FigMisc.ERROR_BAR_MAGNITUDE + y_min_with_error = np.nanmin(y) - error_magnitude + y_max_with_error = np.nanmax(y) + error_magnitude + ax.set_ylim([np.floor(y_min_with_error*100)/100, np.ceil(y_max_with_error*100)/100]) + else: + ax.set_ylim([np.floor(np.nanmin(y)*100)/100, np.ceil(np.nanmax(y)*100)/100]) + + # Add legend for composition lines if enabled + if FigMisc.DRAW_COMPOSITION_LINE and Params.LEGEND: + legend1 = ax.legend(fontsize=FigMisc.LOVE_COMP_LEGEND_FONT_SIZE, + loc='upper right', bbox_to_anchor=(1.0, 1.0)) + ax.add_artist(legend1) + + # Add second legend for convection markers if enabled + if FigMisc.SHOW_CONVECTION_WITH_SHAPE and Params.LEGEND: + legend_elements = [ + Line2D([0], [0], marker='v', color='w', markerfacecolor='gray', + markersize=8, label='Convection'), + Line2D([0], [0], marker='^', color='w', markerfacecolor='gray', + markersize=8, label='No Convection') + ] + legend2 = ax.legend(handles=legend_elements, fontsize=FigMisc.LOVE_COMP_LEGEND_FONT_SIZE, + loc='lower left', bbox_to_anchor=(0.0, 0.0)) + ax.add_artist(legend2) + # Save the plot + plt.tight_layout() + fig.savefig(Params.FigureFiles.exploreLoveComparison, format=FigMisc.figFormat, + dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) + log.debug(f'Plot saved to file: {Params.FigureFiles.exploreLoveComparison}') + plt.close() + + + + + +# Unused Functions + +def PlotExploreOgramXZ(results_list, FigureFilesList, Params): + """ + Plots scatter showing any x variable vs any z variable with ocean thickness as color, + with optional ocean composition lines. The x variable is configurable via Params.XZPLOT_X_VARIABLE + (defaults to 'D_km' for ocean thickness), and the y variable is taken from the result's zName. + Ocean thickness (zb_km) is used as the color variable. + + Similar to PlotExploreOgramZbD but with configurable axes and different color variable. + + Usage: + # To change the x variable, set Params.XZPLOT_X_VARIABLE before calling this function: + # Params.XZPLOT_X_VARIABLE = 'Tb_K' # Use ocean temperature as x-axis + # Params.XZPLOT_X_VARIABLE = 'wOcean_ppt' # Use ocean salinity as x-axis + # Params.XZPLOT_X_VARIABLE = 'Pseafloor_MPa' # Use seafloor pressure as x-axis + Args: + results_list: List of ExplorationResults objects (individual + optional comparison) + Params: Configuration parameters + """ + + # Set up basic figure labels using first result + first_result = results_list[0] + first_yName = first_result.zName + # Use configurable x variable instead of hardcoded 'D_km' + x_variable = Params.XZPLOT_X_VARIABLE + FigLbl.SetExploration(first_result.bodyname, x_variable, first_yName, 'zb_km') + if not FigMisc.TEX_INSTALLED: + FigLbl.StripLatex() + + # Plot each result (individual exploration results + optional comparison) + last_index = len(results_list) - 1 + for i, result in enumerate(results_list): + FigureFiles = FigureFilesList[i] + Params.FigureFiles = FigureFiles + # Skip results with no H2O + if result.base.NO_H2O: + continue + # Use configurable x variable instead of hardcoded 'D_km' + xName = Params.XZPLOT_X_VARIABLE + # Set axis variable names for this plot type + _, result.zName = getIceShellThickness(result) + + # Detect if this is a comparison plot (last result when COMPARE=True) + is_comparison_plot = Params.COMPARE and i == last_index + + # Extract and validate data using helper + plot_data = extract_and_validate_plot_data(result_obj = result, x_field = xName, y_field = xName, c_field = result.zName, + x_multiplier = FigLbl.xMultExplore, y_multiplier = FigLbl.yMultExplore, c_multiplier = FigLbl.zMultExplore, + custom_x_axis = FigLbl.xCustomAxis, custom_y_axis = FigLbl.yCustomAxis) + + if len(plot_data['x']) == 0: + log.warning(f"No valid data points for {result.bodyname}") + continue + + # Create figure and axis + fig = plt.figure(figsize=FigSize.explore) + grid = GridSpec(1, 1) + ax = fig.add_subplot(grid[0, 0]) + if Style.GRIDS: + ax.grid() + ax.set_axisbelow(True) + + # Set title based on plot type + if Params.TITLES: + if is_comparison_plot: + fig.suptitle(FigLbl.exploreCompareTitle) + else: + fig.suptitle(FigLbl.explorationYTitle) + + # Set up axis labels and scales + ax.set_xlabel(FigLbl.xLabelExplore) + ax.set_ylabel(FigLbl.yLabelExplore) + ax.set_xscale('linear') # ZbY plots use linear scales + ax.set_yscale('linear') + + # Extract plot data + x, y, z = plot_data['x'], plot_data['y'], plot_data['c'] + + # Set axis limits with padding + if np.size(x) > 0: + x_range = np.nanmax(x) - np.nanmin(x) + y_range = np.nanmax(y) - np.nanmin(y) + x_padding = x_range * FigMisc.ZBD_AXIS_PADDING + y_padding = y_range * FigMisc.ZBD_AXIS_PADDING + ax.set_xlim([np.nanmin(x) - x_padding, np.nanmax(x) + x_padding]) + ax.set_ylim([np.nanmin(y) - y_padding, np.nanmax(y) + y_padding]) + + # Get ocean composition data for composition lines + ocean_comp = result.base.oceanComp.flatten() + + + # Separate NaN and non-NaN points for different plotting + nan_mask = np.isnan(y) # Check for NaN in y variable (the specified property) + valid_mask = ~nan_mask + + # Plot non-NaN points colored by ocean thickness (z variable) + if np.any(valid_mask): + z_valid = z[valid_mask] + pts = ax.scatter(x[valid_mask], y[valid_mask], c=z_valid, + cmap=FigMisc.ZBD_COLORMAP, marker=Style.MS_Induction, + s=Style.MW_Induction**2, edgecolors=FigMisc.ZBD_DOT_EDGE_COLOR, + linewidths=FigMisc.ZBD_DOT_EDGE_WIDTH, zorder=3, + vmin=np.nanmin(z_valid), vmax=np.nanmax(z_valid)) + # Draw composition lines if enabled using helper + if FigMisc.DRAW_COMPOSITION_LINE: + draw_ocean_composition_lines(ax, x[valid_mask], y[valid_mask], z[valid_mask], ocean_comp[valid_mask], + FigMisc.MANUAL_HYDRO_COLORS, + FigMisc.ZBD_COMP_LINE_WIDTH, FigMisc.ZBD_COMP_LINE_ALPHA) + + # Add colorbar + cbar = fig.colorbar(pts, ax=ax, format=FigLbl.cbarFmt) + # Set colorbar ticks to match every unique ice shell thickness value + if np.size(z_valid) > 0: + # Get unique ice shell thickness values (x contains the ice shell thicknesses) + unique_ice_thicknesses = np.unique(z_valid) + # Set ticks to every unique ice shell thickness + cbar.set_ticks(unique_ice_thicknesses) + pts.set_clim(vmin=np.min(z_valid), vmax=np.max(z_valid)) + cbar.set_label(FigLbl.cbarLabelExplore, size=12) + + + + # Add legends if enabled + if FigMisc.DRAW_COMPOSITION_LINE and Params.LEGEND: + ax.legend(title="Ocean Composition", fontsize=FigMisc.ZBD_COMP_LEGEND_FONT_SIZE, + title_fontsize=FigMisc.ZBD_COMP_LEGEND_TITLE_SIZE) + elif np.any(nan_mask) and Params.LEGEND: + # Show legend for invalid data points if no composition lines are shown + ax.legend(fontsize=FigMisc.ZBD_COMP_LEGEND_FONT_SIZE) + + # Save the plot + plt.tight_layout() + fig.savefig(Params.FigureFiles.exploreZbY, format=FigMisc.figFormat, + dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) + log.debug(f'Plot saved to file: {Params.FigureFiles.exploreZbY}') + plt.close() + + +def PlotImaginaryVersusReal(results_list, FigureFilesList, Params, ax=None): + """ + Creates subplots for each excitation/peak with ice thickness-based zoom insets similar to + PlotMonteCarloScatter. Features composition-based coloring, marker shapes per excitation, + and configurable ice thickness highlighting with tolerance-based selection. + + Args: + results_list: List of result objects (MonteCarloResults, ExplorationResults, etc.) + Params: Configuration parameters + ax: Optional existing axis (if None, creates new figure with subplots) + + Configuration flags: + FigMisc.HIGHLIGHT_ICE_THICKNESSES: Enable ice thickness highlighting + FigMisc.DO_SCATTER_INSET: Enable zoom insets + FigMisc.ICE_THICKNESSES_TO_SHOW: Target ice thicknesses to highlight + FigMisc.ICE_THICKNESS_TOLERANCE: Tolerance for ice thickness matching + """ + + if not results_list: + log.warning("No results provided for complex plotting") + return + + + # For now, only handle magnetic data types + data_types_to_try = ['magnetic'] + for data_type in data_types_to_try: + # Extract complex data from first result to determine data type and setup + first_result = results_list[0] + plot_data = extract_complex_plot_data(first_result, data_type=data_type, Params=Params) + + if not plot_data or not plot_data.get('complex_data'): + continue # Try next data type + + data_type = plot_data['data_type'] + componentsAvailable = plot_data['components'] + excitation_names = plot_data['excitation_names'] + n_peaks = len(excitation_names) + + log.info(f"Plotting {data_type} complex data with {len(componentsAvailable)} components and {n_peaks} peaks") + + + # Check if ice thickness highlighting is enabled + highlight_ice_thickness = (FigMisc.HIGHLIGHT_ICE_THICKNESSES and + first_result.base.zb_km is not None) + + if FigMisc.EDGE_COLOR_K_IN_COMPLEX_PLOTS and data_type == 'magnetic': + kData = first_result.base.kLoveAmp + if np.all(np.isnan(kData)): + EDGE_COLOR_WITH_K = False + else: + EDGE_COLOR_WITH_K = True + cmap = plt.cm.viridis # Use viridis colormap for edge colors + k_norm, edgeColor, cBarTitle, cBarFmt, cTicksSpacings = normalizeDataForColor(kData, 'kLoveAmp', cmap) + edgeColor = edgeColor.reshape(-1, 4) + else: + EDGE_COLOR_WITH_K = False + + if not EDGE_COLOR_WITH_K: + # Create array of black colors matching Tb_K shape + edgeColor = np.full(first_result.base.Tb_K.shape + (4,), [0, 0, 0, 1]).reshape(-1, 4) + + # Plot each component separately + for comp in componentsAvailable: + n_cols = int(np.ceil(np.sqrt(n_peaks))) + n_rows = int(np.ceil(n_peaks / n_cols)) + + + # Create figure with subplots + if ax is None: + fig, axes = plt.subplots(n_rows, n_cols, figsize=(FigSize.imaginaryRealSoloCombo[0] * n_cols, FigSize.imaginaryRealSoloCombo[1] * n_rows)) + # Ensure axes is always 2D array for consistent indexing + if n_rows == 1 and n_cols == 1: + axes = np.array([[axes]]) + elif n_rows == 1: + axes = axes.reshape(1, -1) + elif n_cols == 1: + axes = axes.reshape(-1, 1) + else: + fig = ax.figure + axes = np.array([[ax]]) + n_rows, n_cols = 1, 1 + # Set main title + if Params.TITLES: + if len(set(getattr(result.base, 'bodyname', 'Unknown') for result in results_list)) == 1: + bodyname = getattr(results_list[0].base, 'bodyname', 'Unknown') + if highlight_ice_thickness: + fig.suptitle(f'{bodyname} {FigLbl.BdipTitle} - {comp} (Ice Thickness Highlighted)', fontsize=14) + else: + fig.suptitle(f'{bodyname} {FigLbl.BdipTitle} - {comp}', fontsize=14) + else: + if highlight_ice_thickness: + fig.suptitle(f'{FigLbl.BdipCompareTitle} - {comp} (Ice Thickness Highlighted)', fontsize=14) + else: + fig.suptitle(f'{FigLbl.BdipCompareTitle} - {comp}', fontsize=14) + + # Create subplot for each excitation/peak + for peak_idx, exc_name in enumerate(excitation_names): + row, col = peak_idx // n_cols, peak_idx % n_cols + ax_subplot = axes[row, col] + + if Style.GRIDS: + ax_subplot.grid() + ax_subplot.set_axisbelow(True) + + # Set axis labels + if data_type == 'magnetic': + if col == 0: + ax_subplot.set_ylabel(FigLbl.BdipImLabel[comp]) + if row == n_rows-1: + ax_subplot.set_xlabel(FigLbl.BdipReLabel[comp]) + + # Set subplot title + if data_type == 'magnetic': + ax_subplot.set_title(f'{exc_name.capitalize()}') + else: + ax_subplot.set_title(f'{exc_name.capitalize()} - {comp}') + + # Collect all data for this peak across all results + all_real_data = np.array([]) + all_imag_data = np.array([]) + all_ice_thickness = np.array([]) + all_ocean_comps = np.array([]) + all_marker_sizes = np.array([]) + legendElements = [] + + for i_result, result in enumerate(results_list): + plot_data = extract_complex_plot_data(result, data_type=data_type, Params=Params) + complex_data = plot_data['complex_data'] + + # Get ice shell thickness for this result + iceShellThickness, zb_name = getIceShellThickness(result) + + # Get composition data + oceanComps = result.base.oceanComp + + # Normalize ice shell thickness and convert to marker sizes + if np.max(iceShellThickness) - np.min(iceShellThickness) > FigMisc.ICE_THICKNESS_TOLERANCE: + iceShellThicknessNormalized = (iceShellThickness - np.min(iceShellThickness)) / (np.max(iceShellThickness) - np.min(iceShellThickness)) + min_size = 10 + max_size = 90 + marker_sizes = min_size + iceShellThicknessNormalized * (max_size - min_size) + else: + marker_sizes = np.full_like(iceShellThickness, fill_value=50) + + # Extract data for this peak + excData = complex_data[comp][peak_idx] + real_val = np.real(excData) + imag_val = np.imag(excData) + + # Accumulate data + all_real_data = np.append(all_real_data, real_val) + all_imag_data = np.append(all_imag_data, imag_val) + all_ice_thickness = np.append(all_ice_thickness, iceShellThickness) + all_ocean_comps = np.append(all_ocean_comps, oceanComps) + all_marker_sizes = np.append(all_marker_sizes, marker_sizes) + + # Determine ice thickness highlighting mask if enabled + highlight_mask = None + if highlight_ice_thickness: + highlight_mask = np.zeros(len(all_real_data), dtype=bool) + for target_thickness in FigMisc.ICE_THICKNESSES_TO_SHOW: + thickness_mask = np.abs(all_ice_thickness - target_thickness) <= FigMisc.ICE_THICKNESS_TOLERANCE + highlight_mask |= thickness_mask + + if EDGE_COLOR_WITH_K: + k_norm, edgeColor, cBarTitle, cBarFmt, cTicksSpacings = normalizeDataForColor(kData, 'kLoveAmp', cmap, highlight_mask) + # Group by unique ocean compositions (maintaining order) and plot connecting lines + uniqueOceanComps, indices = np.unique(all_ocean_comps, return_index=True) + uniqueOceanComps = uniqueOceanComps[np.argsort(indices)] + + # Track plotted dimmed coordinates to prevent overlapping brightness + plotted_dimmed_coords = set() + + for comp_idx, ocean_comp in enumerate(uniqueOceanComps): + oceanCompLabel = formatOceanCompositionLabel(ocean_comp) + comp_mask = all_ocean_comps == ocean_comp + + if not np.any(comp_mask): + continue + + color = Color.cmap[ocean_comp](0.5) + markerShape = Style.MS_dip[exc_name] if data_type == 'magnetic' else 'o' + + # Get data for this composition + comp_real = all_real_data[comp_mask] + comp_imag = all_imag_data[comp_mask] + comp_sizes = all_marker_sizes[comp_mask] + comp_edge_color = edgeColor[comp_mask] + + + if highlight_ice_thickness: + # Plot highlighted points (full alpha) + highlight_comp_mask = highlight_mask[comp_mask] + highlight_edge_color = comp_edge_color[highlight_comp_mask] + if np.any(highlight_comp_mask): + ax_subplot.scatter(comp_real[highlight_comp_mask], comp_imag[highlight_comp_mask], + facecolor=color, edgecolor=highlight_edge_color, linewidths=3, + s=comp_sizes[highlight_comp_mask], marker=markerShape, zorder=3, + alpha=1.0, label=f'{oceanCompLabel}' if comp_idx == 0 else "") + + # Plot dimmed points (lower alpha) - but only if not already plotted + dimmed_comp_mask = ~highlight_comp_mask + if np.any(dimmed_comp_mask): + # Filter out coordinates that have already been plotted + dimmed_real = comp_real[dimmed_comp_mask] + dimmed_imag = comp_imag[dimmed_comp_mask] + dimmed_sizes = comp_sizes[dimmed_comp_mask] + + # Check which points haven't been plotted yet + # This is a hack to prevent overlapping points from being plotted and increasing the alpha of the dimmed points + unplotted_mask = np.array([ + (round(x, 1), round(y, 1)) not in plotted_dimmed_coords + for x, y in zip(dimmed_real, dimmed_imag) + ]) + + if np.any(unplotted_mask): + # Plot only unplotted points + unplotted_real = dimmed_real[unplotted_mask] + unplotted_imag = dimmed_imag[unplotted_mask] + unplotted_sizes = dimmed_sizes[unplotted_mask] + + ax_subplot.scatter(unplotted_real, unplotted_imag, + facecolor=color, edgecolor='none', + s=unplotted_sizes, marker=markerShape, zorder=1, + alpha=0.2, label="") + + # Add these coordinates to the plotted set + for x, y in zip(unplotted_real, unplotted_imag): + plotted_dimmed_coords.add((round(x, 1), round(y, 1))) + + else: + # Standard plotting without highlighting + ax_subplot.scatter(comp_real, comp_imag, + facecolor=color, edgecolor=comp_edge_color, linewidths=3, + s=comp_sizes, marker=markerShape, + alpha=1.0, label=f'{oceanCompLabel}') + + # Add k2 value labels above points if enabled and few enough points + if EDGE_COLOR_WITH_K and len(np.unique(kData[~np.isnan(kData)])) <= 5: + for i, (x, y) in enumerate(zip(comp_real, comp_imag)): + # Find the k value for this point + original_idx = np.where((all_real_data == x) & (all_imag_data == y))[0] + if len(original_idx) > 0: + k_idx = original_idx[0] # Take first match if multiple + if k_idx < len(kData) and not np.isnan(kData.flatten()[k_idx]): + ax_subplot.annotate(f'{kData.flatten()[k_idx]:.3f}', + (x, y), + xytext=(0, 8), + textcoords='offset points', + ha='center', va='bottom', + fontsize=8, + alpha=0.8) + legendElements.append(plt.Line2D([0], [0], marker=markerShape, color='none', markerfacecolor=color, markeredgecolor='black', markeredgewidth=3, + markersize=np.sqrt(comp_sizes[0]), label=f'{oceanCompLabel}')) + if not FigMisc.DO_SCATTER_INSET: + DO_INSET = False + elif not highlight_ice_thickness or not np.any(highlight_mask): + DO_INSET = False + else: + DO_INSET = True + + # Create zoom inset if highlighting enabled and there are highlighted points + inset_ax = None + if DO_INSET: + inset_ax, optimal_location = create_zoom_inset(ax_subplot, all_real_data, all_imag_data, + highlight_mask, add_visual_indicators=True) + + if inset_ax is not None: + # Plot only highlighted points in inset + for comp_idx, ocean_comp in enumerate(uniqueOceanComps): + comp_mask = all_ocean_comps == ocean_comp + highlight_comp_mask = highlight_mask & comp_mask + + if np.any(highlight_comp_mask): + color = Color.cmap[ocean_comp](0.5) + markerShape = Style.MS_dip[exc_name] if data_type == 'magnetic' else 'o' + + inset_ax.scatter(all_real_data[highlight_comp_mask], all_imag_data[highlight_comp_mask], + facecolor=color, edgecolor=edgeColor[highlight_comp_mask], + s=10, marker=markerShape, + alpha=1.0) + ax_subplot.set_xlim(left=0) + ax_subplot.set_ylim(bottom=0) + + + # Set up grid with configurable spacing + axs_to_format = [ax_subplot] + if inset_ax is not None: + axs_to_format.append(inset_ax) + + for ax_format in axs_to_format: + """points = ax_format.dataLim.get_points() + # returns [[xmin, ymin], [xmax, ymax]] + xmax, ymax = points[1] + xmin, _ = ax_format.get_xlim() + ymin, _ = ax_format.get_ylim() + ax_format.set(xlim=(xmin, xmax), ylim=(ymin, ymax))""" + minor_spacing = 3.0 + + # Calculate major spacing as a multiple of minor spacing based on axis limits + x_range = ax_format.get_xlim()[1] - ax_format.get_xlim()[0] + y_range = ax_format.get_ylim()[1] - ax_format.get_ylim()[0] + + # Determine appropriate major spacing multiplier based on range + x_major_multiplier = max(1, int(x_range / (minor_spacing * 4))) + y_major_multiplier = max(1, int(y_range / (minor_spacing * 4))) + major_multiplier = min(x_major_multiplier, y_major_multiplier) + + x_major_spacing = minor_spacing * major_multiplier + y_major_spacing = minor_spacing * major_multiplier + # Round axis limits to the nearest ceiling major spacing + current_xlim = ax_format.get_xlim() + current_ylim = ax_format.get_ylim() + + # Calculate ceiling values rounded to major spacing + new_xmax = np.ceil(current_xlim[1] / minor_spacing) * minor_spacing + new_ymax = np.ceil(current_ylim[1] / minor_spacing) * minor_spacing + + new_xlim = (current_xlim[0], new_xmax) + new_ylim = (current_ylim[0], new_ymax) + + ax_format.set_xlim(new_xlim) + ax_format.set_ylim(new_ylim) + + ax_format.xaxis.set_major_locator(ticker.MultipleLocator(x_major_spacing)) + ax_format.yaxis.set_major_locator(ticker.MultipleLocator(y_major_spacing)) + ax_format.xaxis.set_minor_locator(ticker.MultipleLocator(minor_spacing)) + ax_format.yaxis.set_minor_locator(ticker.MultipleLocator(minor_spacing)) + ax_format.grid(which='minor', alpha=0.3) + ax_format.grid(which='major', alpha=0.5) + + + # Hide unused subplots + for i in range(n_peaks, n_rows * n_cols): + row, col = i // n_cols, i % n_cols + axes[row, col].set_visible(False) + + if EDGE_COLOR_WITH_K: + fig.colorbar(plt.cm.ScalarMappable(norm=k_norm, cmap=cmap), ax=axes[0, n_cols-1], label=cBarTitle, format=cBarFmt, ticks=cTicksSpacings) + + # Add marker size legend to show ice shell thickness relationship + if Params.LEGEND: + add_composition_legend(axes[0, n_cols-1], uniqueOceanComps, show_ice_thickness_legend=False, overrideLegend = legendElements) + # Get unique ice thickness and marker size pairs that actually exist in the data + unique_pairs = [] + for thickness, size in zip(all_ice_thickness, all_marker_sizes): + pair = (round(thickness, 1), round(size, 1)) + if pair not in unique_pairs: + unique_pairs.append(pair) + if len(unique_pairs) > 1: + # Sort by ice thickness for consistent ordering + unique_pairs.sort(key=lambda x: x[0]) + + # Limit to max 5 legend entries to avoid overcrowding + if len(unique_pairs) > 5: + # Select evenly spaced entries including first and last + indices = np.linspace(0, len(unique_pairs)-1, 5, dtype=int) + unique_pairs = [unique_pairs[i] for i in indices] + + legend_elements = [] + for thickness, size in unique_pairs: + legend_elements.append( + plt.Line2D([0], [0], marker=markerShape, color='w', markerfacecolor='gray', markeredgecolor='black', markeredgewidth=np.sqrt(3), + markersize=np.sqrt(size), label=f'{thickness:.1f} {FigLbl.distanceUnits}') + ) + + axes[n_rows-1, n_cols-1].legend(handles=legend_elements, fontsize='small', + title=FigLbl.zbLabel, loc= 'center left') + # Save the plot if we created a new figure + if ax is None: + plt.tight_layout() + if data_type == 'magnetic': + save_path = Params.FigureFiles.BdipExplore[comp] + else: + # For Love numbers, create file path + save_path = getattr(Params.FigureFiles, 'Love', {}).get(comp, f'love_numbers_{comp}.png') + + fig.savefig(save_path, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) + log.debug(f'Complex {data_type} {comp} subplot plot saved to file: {save_path}') + plt.close() + + return \ No newline at end of file diff --git a/PlanetProfile/Plotting/MagPlots.py b/PlanetProfile/Plotting/MagPlots.py index b9120c31..a1d56bfc 100644 --- a/PlanetProfile/Plotting/MagPlots.py +++ b/PlanetProfile/Plotting/MagPlots.py @@ -12,13 +12,14 @@ from PlanetProfile.GetConfig import Color, Style, FigLbl, FigSize, FigMisc from PlanetProfile.Utilities.defineStructs import xyzComps, vecComps from PlanetProfile.MagneticInduction.Moments import Excitations +from PlanetProfile.Plotting.EssentialHelpers import get_excitation_indices_and_names, countPlottableExcitations, formatOceanCompositionLabel from MoonMag.asymmetry_funcs import getMagSurf as GetMagSurf +from PlanetProfile.Thermodynamics.Reaktoro.CustomSolution import SetupCustomSolutionPlotSettings # Assign logger log = logging.getLogger('PlanetProfile') def GenerateMagPlots(PlanetList, Params): - # Catch if we get here and induction calcs have not been done if np.all([Planet.Magnetic.Binm_nT is not None for Planet in PlanetList]): @@ -53,101 +54,75 @@ def GenerateMagPlots(PlanetList, Params): return - -def PlotInductOgramPhaseSpace(InductionList, Params): - """ For plotting points showing the various models used in making - inductogram plots. +def GenerateExplorationMagPlots(ResultsList, FigureFilesList, Params): + """ + Generate magnetic induction plots for a given list of results. """ + # Catch if we get here and induction calcs have not been done + if not np.all([np.all(np.isnan(Result.induction.Amp)) for Result in ResultsList]): + # Setup CustomSolution plot settings + all_ocean_comps = [] + for Result in ResultsList: + all_ocean_comps.extend(np.array(Result.base.oceanComp).flatten()) + Params = SetupCustomSolutionPlotSettings(np.array(all_ocean_comps), Params) + PlotInductOgramPhaseSpace(ResultsList, FigureFilesList, Params) + PLOT_2D = not np.any([ResultsList[i].nx == 1 or ResultsList[i].ny == 1 for i in range(len(ResultsList))]) # If either axis is of size 1, then we cannot plot + if PLOT_2D: + PlotInductOgram(ResultsList, FigureFilesList, Params) + - if InductionList[0].SINGLE_COMP: - FigLbl.singleComp(InductionList[0].comps[0]) - FigLbl.SetInduction(InductionList[0].bodyname, Params.Induct, InductionList[0].Texc_hr.values()) +def PlotInductOgramPhaseSpace(results_list, FigureFilesList, Params): + # Get first result for configuration - assume all have same body and settings + first_result = results_list[0] + + # Check if this is single composition setup + if hasattr(first_result, 'SINGLE_COMP') and first_result.SINGLE_COMP: + if hasattr(first_result, 'comps'): + FigLbl.singleComp(first_result.comps[0]) + + # Set up figure labels using original pattern + texc_values = first_result.induction.Texc_hr + FigLbl.SetInduction(first_result.bodyname, Params.Induct, texc_values) if not FigMisc.TEX_INSTALLED: FigLbl.StripLatex() - # Initialize data arrays - sigma_Sm, D_km, ptColors, x_data, y_data, iValid, iValidFlat \ - = (np.empty_like(InductionList) for _ in range(7)) + # Initialize data arrays for all results + n_results = len(results_list) + sigma_Sm = np.empty(n_results, dtype=object) + D_km = np.empty(n_results, dtype=object) + ptColors = np.empty(n_results, dtype=object) + x_data = np.empty(n_results, dtype=object) + y_data = np.empty(n_results, dtype=object) + oceanComp = np.empty(n_results, dtype=object) + iValid = np.empty(n_results, dtype=object) + iValidFlat = np.empty(n_results, dtype=object) - for i, Induction in enumerate(InductionList): - iValid[i] = np.where(np.isfinite(Induction.Amp[0,...])) - iValidFlat[i] = np.where(np.isfinite(Induction.Amp[0,...].flatten()))[0] - sigma_Sm[i] = Induction.sigmaMean_Sm[iValid[i]].flatten() - D_km[i] = Induction.D_km[iValid[i]].flatten() - - # Get axis data based on inductOtype - if Params.Induct.inductOtype == 'sigma': - x_data[i] = Induction.sigmaMean_Sm[iValid[i]].flatten() - y_data[i] = Induction.D_km[iValid[i]].flatten() - elif Params.Induct.inductOtype == 'oceanComp': - x_data[i] = Induction.oceanComp[iValid[i]].flatten() - y_data[i] = Induction.zb_approximate_km[iValid[i]].flatten() + for i, result in enumerate(results_list): + # Get valid data indices using first excitation (maintaining original behavior) + if hasattr(result.induction, 'Amp') and result.induction.Amp is not None: + iValid[i] = np.where(np.isfinite(result.induction.Amp[0,...])) + iValidFlat[i] = np.where(np.isfinite(result.induction.Amp[0,...].flatten()))[0] else: - x_data[i] = Induction.x[iValid[i]].flatten() - y_data[i] = Induction.y[iValid[i]].flatten() + # Fallback if no magnetic data - use sigma data for validity + iValid[i] = np.where(np.isfinite(result.base.sigmaMean_Sm)) + iValidFlat[i] = np.where(np.isfinite(result.base.sigmaMean_Sm.flatten()))[0] - # Handle coloring - if Params.Induct.inductOtype == 'sigma': - # In this case, we likely don't have salinity and ocean temp information - # so we need to set the colormap to use the info we do have - sigmaNorm = sigma_Sm[i] / 10**Params.Induct.sigmaMax[Induction.bodyname] - Dnorm = D_km[i] / np.max(D_km) - ptColors[i] = Color.OceanCmap(Induction.compsList[iValidFlat[i]], sigmaNorm, Dnorm, - DARKEN_SALINITIES=FigMisc.DARKEN_SALINITIES) - elif Params.Induct.inductOtype == 'oceanComp': - # For oceanComp, use a different coloring scheme - # Color by ice shell thickness or another appropriate variable - if Params.Induct.colorType == 'Tmean': - cbar_data = Induction.Tmean_K[iValid[i]].flatten() - else: - cbar_data = Induction.zb_km[iValid[i]].flatten() - if np.size(cbar_data) > 1: - cbar_norm = interp1d([np.min(cbar_data), np.max(cbar_data)], [0.0, 1.0])(cbar_data) - else: - cbar_norm = np.ones_like(cbar_data) * 0.5 - # Use a simple color scheme for ocean compositions - ptColors[i] = Color.OceanCmap(Induction.compsList[iValidFlat[i]], cbar_norm, cbar_norm, - DARKEN_SALINITIES=FigMisc.DARKEN_SALINITIES) - else: - # Standard coloring for other inductOtypes - Tmean_K = Induction.Tmean_K[iValid[i]].flatten() - if FigMisc.NORMALIZED_SALINITIES: - wMax_ppt = np.array([Color.saturation[comp] for comp in Induction.compsList[iValidFlat[i]]]) - if hasattr(Induction, 'x') and np.size(Induction.x[iValid[i]]) > 0: - w_normFrac = Induction.x[iValid[i]].flatten() / wMax_ppt - else: - w_normFrac = np.ones_like(Tmean_K) * 0.5 - else: - if hasattr(Induction, 'x') and np.size(Induction.x[iValid[i]]) > 0: - x_vals = Induction.x[iValid[i]].flatten() - if np.max(x_vals) > np.min(x_vals): - w_normFrac = interp1d([np.min(x_vals), np.max(x_vals)], [0.0, 1.0])(x_vals) - else: - w_normFrac = np.ones_like(x_vals) * 0.5 - else: - w_normFrac = np.ones_like(Tmean_K) * 0.5 - - if Params.Induct.colorType == 'Tmean': - if FigMisc.NORMALIZED_TEMPERATURES: - Tmean_normFrac = Color.GetNormT(Tmean_K) - else: - if np.max(Tmean_K) > np.min(Tmean_K): - Tmean_normFrac = interp1d([np.min(Tmean_K), np.max(Tmean_K)], [0.0, 1.0])(Tmean_K) - else: - Tmean_normFrac = np.ones_like(Tmean_K) * 0.5 - ptColors[i] = Color.OceanCmap(Induction.compsList[iValidFlat[i]], w_normFrac, Tmean_normFrac, - DARKEN_SALINITIES=FigMisc.DARKEN_SALINITIES) - elif Params.Induct.colorType == 'zb': - zb_km = Induction.zb_km[iValid[i]].flatten() - if np.max(zb_km) > np.min(zb_km): - zb_normFrac = interp1d([np.min(zb_km), np.max(zb_km)], [0.0, 1.0])(zb_km) - else: - zb_normFrac = np.ones_like(zb_km) * 0.5 - ptColors[i] = Color.OceanCmap(Induction.compsList[iValidFlat[i]], w_normFrac, zb_normFrac, - DARKEN_SALINITIES=FigMisc.DARKEN_SALINITIES) - else: - raise ValueError(f'Inductogram colortype {Params.Induct.colorType} not recognized.') + # Extract plot data using helper + plot_data = extractInductionData(result, Params.Induct.inductOtype, + iValid[i]) + + sigma_Sm[i] = plot_data['sigma_Sm'] + D_km[i] = plot_data['D_km'] + x_data[i] = plot_data['x_data'] + y_data[i] = plot_data['y_data'] + oceanComp[i] = plot_data['comp_list'] + + # Set up coloring using helper + ptColors[i] = setup_induction_coloring(result, Params.Induct.inductOtype, + Params.Induct.colorType, iValid[i], iValidFlat[i], + sigma_Sm[i], D_km[i], oceanComp[i]) + # Set up plot layout parameters widthPlot = 25 widthCbar = 1 @@ -158,105 +133,125 @@ def PlotInductOgramPhaseSpace(InductionList, Params): y_scale = FigLbl.yScaleInduct x_lims, y_lims = None, None - if Params.Induct.inductOtype == 'sigma': - comps = ['Ice'] - fig = plt.figure(figsize=FigSize.phaseSpaceSolo, constrained_layout=True) - grid = GridSpec(1, 2, width_ratios=[widthPlot, widthCbar], figure=fig) - axes = [fig.add_subplot(grid[0, 0])] - if Style.GRIDS: - axes[0].grid() - axes[0].set_axisbelow(True) - cbarAx = fig.add_subplot(grid[0, 1]) - cbarUnits = InductionList[0].zb_km[iValid[0]].flatten() - cbarLabel = FigLbl.iceThickLbl - else: - comps = np.unique(InductionList[0].comps) - if Params.Induct.colorType == 'Tmean': - cbarUnits = InductionList[0].Tmean_K[iValid[0]].flatten() - cbarLabel = FigLbl.oceanTempLbl - elif Params.Induct.colorType == 'zb': - cbarUnits = InductionList[0].zb_km[iValid[0]].flatten() + for i, result in enumerate(results_list): + FigureFiles = FigureFilesList[i] + Params.FigureFiles = FigureFiles + # Create figure based on inductOtype (maintaining original complex logic) + if Params.Induct.inductOtype == 'sigma': + oceanComp = ['Ice'] + fig = plt.figure(figsize=FigSize.phaseSpaceSolo, constrained_layout=True) + grid = GridSpec(1, 2, width_ratios=[widthPlot, widthCbar], figure=fig) + axes = [fig.add_subplot(grid[0, 0])] + if Style.GRIDS: + axes[0].grid() + axes[0].set_axisbelow(True) + cbarAx = fig.add_subplot(grid[0, 1]) + cbarUnits = results_list[0].base.zb_km[iValid[0]].flatten() cbarLabel = FigLbl.iceThickLbl - - fig = plt.figure(figsize=FigSize.phaseSpaceCombo, constrained_layout=True) - nComps = np.size(comps) - grid = GridSpec(1, 2 + nComps, width_ratios=np.append([widthPlot, widthPlot], [widthCbar for _ in range(nComps)]), figure=fig) - axes = [fig.add_subplot(grid[0, i]) for i in range(2)] - if Style.GRIDS: - [ax.grid() for ax in axes] - [ax.set_axisbelow(True) for ax in axes] - cbarAxes = [fig.add_subplot(grid[0, i+2]) for i in range(nComps)] - - # Set up the secondary plot (right side) - axes[1].set_xlabel(x_label) - axes[1].set_ylabel(y_label) - axes[1].set_xscale(x_scale) - axes[1].set_yscale(y_scale) - - # Handle different plot types for the secondary plot - if Params.Induct.inductOtype == 'oceanComp': - # For oceanComp, create a categorical scatter plot - # Convert ocean compositions to numeric indices for plotting - unique_comps = np.unique(x_data[0]) - comp_to_idx = {comp: i for i, comp in enumerate(unique_comps)} - x_numeric = np.array([comp_to_idx[comp] for comp in x_data[0]]) - axes[1].scatter(x_numeric, y_data[0], s=Style.MW_Induction, - marker=Style.MS_Induction, c=ptColors[0]) - axes[1].set_xticks(range(len(unique_comps))) - axes[1].set_xticklabels(unique_comps, rotation=45) + else: - axes[1].scatter(x_data[0], y_data[0], s=Style.MW_Induction, - marker=Style.MS_Induction, c=ptColors[0]) + if Params.Induct.colorType == 'Tmean': + cbarUnits = results_list[0].base.Tmean_K[iValid[0]].flatten() + cbarLabel = FigLbl.oceanTempLbl + elif Params.Induct.colorType == 'zb': + cbarUnits = results_list[0].base.zb_km[iValid[0]].flatten() + cbarLabel = FigLbl.iceThickLbl + + fig = plt.figure(figsize=FigSize.phaseSpaceCombo, constrained_layout=True) + nComps = np.size(oceanComp) + grid = GridSpec(1, 2 + nComps, width_ratios=np.append([widthPlot, widthPlot], + [widthCbar for _ in range(nComps)]), figure=fig) + axes = [fig.add_subplot(grid[0, i]) for i in range(2)] + if Style.GRIDS: + [ax.grid() for ax in axes] + [ax.set_axisbelow(True) for ax in axes] + cbarAxes = [fig.add_subplot(grid[0, i+2]) for i in range(nComps)] + + # Set up the secondary plot (right side) + axes[1].set_xlabel(x_label) + axes[1].set_ylabel(y_label) + axes[1].set_xscale(x_scale) + axes[1].set_yscale(y_scale) + + # Handle different plot types for the secondary plot + if Params.Induct.inductOtype == 'oceanComp': + # For oceanComp, create a categorical scatter plot + # Convert ocean compositions to numeric indices for plotting + unique_comps = np.unique(x_data[0]) + comp_to_idx = {comp: i for i, comp in enumerate(unique_comps)} + x_numeric = np.array([comp_to_idx[comp] for comp in x_data[0]]) + axes[1].scatter(x_numeric, y_data[0], s=Style.MW_Induction, + marker=Style.MS_Induction, c=ptColors[0]) + axes[1].set_xticks(range(len(unique_comps))) + axes[1].set_xticklabels(unique_comps, rotation=45) + else: + axes[1].scatter(x_data[0], y_data[0], s=Style.MW_Induction, + marker=Style.MS_Induction, c=ptColors[0]) - # Labels and titles - if Params.TITLES: - fig.suptitle(FigLbl.phaseSpaceTitle) - axes[0].set_xlabel(FigLbl.sigMeanLabel) - axes[0].set_ylabel(FigLbl.Dlabel) - if x_lims is not None: - axes[0].set_xlim(x_lims) - if y_lims is not None: - axes[0].set_ylim(y_lims) - axes[0].set_xscale(FigLbl.sigScale) - axes[0].set_yscale(FigLbl.Dscale) - - pts = {} - cbar = {} - if Params.Induct.inductOtype == 'sigma': - pts[comps[0]] = axes[0].scatter(sigma_Sm[0], D_km[0], s=Style.MW_Induction, - marker=Style.MS_Induction, c=cbarUnits, cmap=Color.cmap[comps[0]]) - cbar[comps[0]] = fig.colorbar(pts[comps[0]], cax=cbarAx) - else: - for comp, cbarAx in zip(comps, cbarAxes): - thisComp = InductionList[0].compsList[iValidFlat[0]] == comp - pts[comp] = axes[0].scatter(sigma_Sm[0][thisComp], D_km[0][thisComp], s=Style.MW_Induction, - marker=Style.MS_Induction, c=cbarUnits[thisComp], cmap=Color.cmap[comp]) - cbar[comp] = fig.colorbar(pts[comp], cax=cbarAx) - lbl = f'\ce{{{comp}}}' - if not FigMisc.TEX_INSTALLED: - lbl = FigLbl.StripLatexFromString(lbl) - cbarAx.set_title(lbl, fontsize=FigMisc.cbarTitleSize) + # Labels and titles + if Params.TITLES: + fig.suptitle(FigLbl.phaseSpaceTitle) + axes[0].set_xlabel(FigLbl.sigMeanLabel) + axes[0].set_ylabel(FigLbl.Dlabel) + if x_lims is not None: + axes[0].set_xlim(x_lims) + if y_lims is not None: + axes[0].set_ylim(y_lims) + axes[0].set_xscale(FigLbl.sigScale) + axes[0].set_yscale(FigLbl.Dscale) - # Only add bounds for supported inductOtypes - if FigMisc.MARK_INDUCT_BOUNDS and Params.Induct.inductOtype != 'oceanComp': - boundStyle = {'ls': Style.LS_BdipInset, 'lw': Style.LW_BdipInset, 'c': Color.BdipInset} - if hasattr(InductionList[0], 'x') and hasattr(InductionList[0], 'y'): - x_vals = InductionList[0].x[iValid[0]].flatten() - y_vals = InductionList[0].y[iValid[0]].flatten() - _ = axes[0].plot(sigma_Sm[0][thisComp][x_vals == np.min(x_vals)], D_km[0][thisComp][x_vals == np.min(x_vals)], **boundStyle) - _ = axes[0].plot(sigma_Sm[0][thisComp][x_vals == np.max(x_vals)], D_km[0][thisComp][x_vals == np.max(x_vals)], **boundStyle) - _ = axes[0].plot(sigma_Sm[0][thisComp][y_vals == np.min(y_vals)], D_km[0][thisComp][y_vals == np.min(y_vals)], **boundStyle) - _ = axes[0].plot(sigma_Sm[0][thisComp][y_vals == np.max(y_vals)], D_km[0][thisComp][y_vals == np.max(y_vals)], **boundStyle) - - cbar[comps[-1]].set_label(cbarLabel) - fig.savefig(Params.FigureFiles.phaseSpace, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) - log.debug(f'InductOgram phase space plot saved to file: {Params.FigureFiles.phaseSpace}') - plt.close() + # Create scatter plots and colorbars + pts = {} + cbar = {} + if Params.Induct.inductOtype == 'sigma': + pts[oceanComp[0]] = axes[0].scatter(sigma_Sm[i], D_km[i], s=Style.MW_Induction, + marker=Style.MS_Induction, c=cbarUnits, cmap=Color.cmap[oceanComp[0]]) + cbar[oceanComp[0]] = fig.colorbar(pts[oceanComp[0]], cax=cbarAx) + else: + # Plot by ocean composition + for comp, cbarAx in zip(oceanComp[i], cbarAxes): + thisComp = oceanComp[i] == comp + pts[comp] = axes[0].scatter(sigma_Sm[i][thisComp], D_km[i][thisComp], s=Style.MW_Induction, + marker=Style.MS_Induction, c=cbarUnits[thisComp], cmap=Color.cmap[comp]) + cbar[comp] = fig.colorbar(pts[comp], cax=cbarAx) + lbl = formatOceanCompositionLabel(comp) + if not FigMisc.TEX_INSTALLED: + lbl = FigLbl.StripLatexFromString(lbl) + cbarAx.set_title(lbl, fontsize=FigMisc.cbarTitleSize) + + # Only add bounds for supported inductOtypes + if FigMisc.MARK_INDUCT_BOUNDS and Params.Induct.inductOtype != 'oceanComp': + boundStyle = {'ls': Style.LS_BdipInset, 'lw': Style.LW_BdipInset, 'c': Color.BdipInset} + if hasattr(results_list[0], 'x') and hasattr(results_list[0], 'y'): + x_vals = results_list[i].x[iValid[i]].flatten() + y_vals = results_list[i].y[iValid[i]].flatten() + _ = axes[0].plot(sigma_Sm[i][thisComp][x_vals == np.min(x_vals)], + D_km[i][thisComp][x_vals == np.min(x_vals)], **boundStyle) + _ = axes[0].plot(sigma_Sm[i][thisComp][x_vals == np.max(x_vals)], + D_km[i][thisComp][x_vals == np.max(x_vals)], **boundStyle) + _ = axes[0].plot(sigma_Sm[i][thisComp][y_vals == np.min(y_vals)], + D_km[i][thisComp][y_vals == np.min(y_vals)], **boundStyle) + _ = axes[0].plot(sigma_Sm[i][thisComp][y_vals == np.max(y_vals)], + D_km[i][thisComp][y_vals == np.max(y_vals)], **boundStyle) + + cbar[comp].set_label(cbarLabel) + fig.savefig(Params.FigureFiles.phaseSpace, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) + log.debug(f'InductOgram phase space plot saved to file: {Params.FigureFiles.phaseSpace}') + plt.close() # Plot combination - if Params.COMPARE and np.size(InductionList) > 1 and Params.Induct.inductOtype != 'sigma': - comps = np.unique(np.append([], [Induction.comps for Induction in InductionList])) - nComps = np.size(comps) + if Params.COMPARE and np.size(results_list) > 1 and Params.Induct.inductOtype != 'sigma': + + # Get all unique compositions from all results + all_comps = [] + for result in results_list: + if hasattr(result, 'comps'): + all_comps.extend(result.comps) + else: + all_comps.extend(np.unique(result.base.oceanComp)) + oceanComp = np.unique(all_comps) + nComps = np.size(oceanComp) + figWidth = FigSize.phaseSpaceSolo[0] + nComps * FigMisc.cbarSpace fig, ax = plt.subplots(1, 1, figsize=(figWidth, FigSize.phaseSpaceSolo[1])) if Style.GRIDS: @@ -277,7 +272,13 @@ def PlotInductOgramPhaseSpace(InductionList, Params): divider = make_axes_locatable(ax) extraPad = 0 - comboCompsList = np.concatenate(tuple(Induction.compsList for Induction in InductionList)) + + # Combine data from all results + if hasattr(results_list[0], 'compsList'): + comboCompsList = np.concatenate(tuple(result.compsList for result in results_list)) + else: + comboCompsList = np.concatenate(tuple(result.base.oceanComp.flatten() for result in results_list)) + comboSigma_Sm = np.concatenate(tuple(sigmai for sigmai in sigma_Sm)) comboD_km = np.concatenate(tuple(Di for Di in D_km)) comboColors = np.concatenate(tuple(ptColi for ptColi in ptColors)) @@ -285,21 +286,24 @@ def PlotInductOgramPhaseSpace(InductionList, Params): comboY_data = np.concatenate(tuple(yi for yi in y_data)) if Params.Induct.colorType == 'Tmean': - comboCbarUnits = np.concatenate(tuple(Induction.Tmean_K[iValid[i]].flatten() for i,Induction in enumerate(InductionList))) + comboCbarUnits = np.concatenate(tuple(result.base.Tmean_K[iValid[i]].flatten() + for i, result in enumerate(results_list))) elif Params.Induct.colorType == 'zb': - comboCbarUnits = np.concatenate(tuple(Induction.zb_km[iValid[i]].flatten() for i,Induction in enumerate(InductionList))) + comboCbarUnits = np.concatenate(tuple(result.base.zb_km[iValid[i]].flatten() + for i, result in enumerate(results_list))) pts = {} - for comp in comps: + for comp in oceanComp: thisComp = comboCompsList == comp pts[comp] = ax.scatter(comboSigma_Sm[thisComp], comboD_km[thisComp], s=Style.MW_Induction, marker=Style.MS_Induction, c=comboColors[thisComp]) cbarAx = divider.new_horizontal(size=FigMisc.cbarSize, pad=FigMisc.cbarSpace + extraPad) extraPad = FigMisc.extraPad cbar = mcbar.ColorbarBase(cbarAx, cmap=Color.cmap[comp], format=FigMisc.cbarFmt, - values=np.linspace(np.min(comboCbarUnits[thisComp]), np.max(comboCbarUnits[thisComp]), FigMisc.nCbarPts)) + values=np.linspace(np.min(comboCbarUnits[thisComp]), + np.max(comboCbarUnits[thisComp]), FigMisc.nCbarPts)) fig.add_axes(cbarAx) - lbl = f'\ce{{{comp}}}' + lbl = f'\\ce{{{formatOceanCompositionLabel(comp)}}}' if not FigMisc.TEX_INSTALLED: lbl = FigLbl.StripLatexFromString(lbl) cbarAx.set_title(lbl, fontsize=FigMisc.cbarTitleSize) @@ -310,112 +314,81 @@ def PlotInductOgramPhaseSpace(InductionList, Params): thisY_data = comboY_data[thisComp] boundStyle = {'ls': Style.LS_BdipInset, 'lw': Style.LW_BdipInset, 'c': Color.BdipInset} if np.size(thisX_data) > 0 and np.size(thisY_data) > 0: - _ = ax.plot(comboSigma_Sm[thisComp][thisX_data == np.min(thisX_data)], comboD_km[thisComp][thisX_data == np.min(thisX_data)], **boundStyle) - _ = ax.plot(comboSigma_Sm[thisComp][thisX_data == np.max(thisX_data)], comboD_km[thisComp][thisX_data == np.max(thisX_data)], **boundStyle) - _ = ax.plot(comboSigma_Sm[thisComp][thisY_data == np.min(thisY_data)], comboD_km[thisComp][thisY_data == np.min(thisY_data)], **boundStyle) - _ = ax.plot(comboSigma_Sm[thisComp][thisY_data == np.max(thisY_data)], comboD_km[thisComp][thisY_data == np.max(thisY_data)], **boundStyle) + _ = ax.plot(comboSigma_Sm[thisComp][thisX_data == np.min(thisX_data)], + comboD_km[thisComp][thisX_data == np.min(thisX_data)], **boundStyle) + _ = ax.plot(comboSigma_Sm[thisComp][thisX_data == np.max(thisX_data)], + comboD_km[thisComp][thisX_data == np.max(thisX_data)], **boundStyle) + _ = ax.plot(comboSigma_Sm[thisComp][thisY_data == np.min(thisY_data)], + comboD_km[thisComp][thisY_data == np.min(thisY_data)], **boundStyle) + _ = ax.plot(comboSigma_Sm[thisComp][thisY_data == np.max(thisY_data)], + comboD_km[thisComp][thisY_data == np.max(thisY_data)], **boundStyle) cbar.set_label(cbarLabel, size=12) plt.tight_layout() - fig.savefig(Params.FigureFiles.phaseSpaceCombo, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(Params.FigureFiles.phaseSpaceCombo, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Plot saved to file: {Params.FigureFiles.phaseSpaceCombo}') plt.close() return -def PlotInductOgram(InductionList, Params): - """ Plot contours showing magnetic induction responses for an array of models - """ - if Params.COMPARE: - CombinedInduction = deepcopy(InductionList[0]) - CombinedInductionList = np.concatenate((InductionList, [CombinedInduction])) - else: - CombinedInductionList = InductionList - - for inductionListIndex, Induction in enumerate(CombinedInductionList): - if inductionListIndex == len(CombinedInductionList) - 1: - print("HERE") +def PlotInductOgram(results_list, FigureFilesList, Params): + for resultListIndex, result in enumerate(results_list): + FigureFiles = FigureFilesList[resultListIndex] + Params.FigureFiles = FigureFiles # Get indices for the oscillations that we can and want to plot - excSelectionCalc = {key:(Texc is not None) for key, Texc in Induction.Texc_hr.items()} - TexcCalcd = excSelectionCalc and Induction.Texc_hr - whichTexc = excSelectionCalc and Params.Induct.excSelectionPlot - allTexc_hr = np.fromiter(TexcCalcd.values(), dtype=np.float_) - allAvailableTexc_hr = allTexc_hr[np.isfinite(allTexc_hr)] - iTexc = [np.where(allTexc_hr == Texc)[0][0] for key, Texc in TexcCalcd.items() if whichTexc[key] and np.size(np.where(allTexc_hr == Texc)[0]) > 0] - iTexcAvail = [np.where(allAvailableTexc_hr == Texc)[0][0] for key, Texc in TexcCalcd.items() if whichTexc[key] and np.size(np.where(allAvailableTexc_hr == Texc)[0]) > 0] - TexcPlotNames = np.fromiter(Induction.Texc_hr.keys(), dtype='= 1 and nPeaksCalced >= 1: + nPeaksToPlot, peaksToPlotNames = countPlottableExcitations(PlanetList[0].Magnetic.calcedExc, Params.Induct) + if nPeaksToPlot >= 1: if nPeaksToPlot == 1: DO_ZOOM = False nCol = 1 @@ -766,7 +636,7 @@ def PlotComplexBdip(PlanetList, Params): [axes[iRow, -1].set_xlim(left=0) for iRow in range(3)] [axes[iRow, -1].set_ylim(bottom=0) for iRow in range(3)] plt.tight_layout() - fig.savefig(Params.FigureFiles.Bdip['all'], format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(Params.FigureFiles.Bdip['all'], format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Induced dipole surface strength plot saved to file: {Params.FigureFiles.Bdip["all"]}') plt.close() @@ -863,7 +733,7 @@ def PlotComplexBdip(PlanetList, Params): axes[-1].set_xlim(left=0) axes[-1].set_ylim(bottom=0) plt.tight_layout() - fig.savefig(Params.FigureFiles.Bdip[axComp], format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(Params.FigureFiles.Bdip[axComp], format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Induced dipole surface strength plot saved to file: {Params.FigureFiles.Bdip[axComp]}') plt.close() @@ -1032,7 +902,7 @@ def PlotMagSurface(PlanetList, Params): if not (Params.CALC_ASYM and FigMisc.BASYM_WITH_SYM): plt.tight_layout() fName = f'{Params.FigureFiles.MagSurf[vCompMagSurf]}{tFnameEnd[iEval]}{FigMisc.xtn}' - fig.savefig(fName, bbox_inches='tight', format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(fName, bbox_inches='tight', format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Induced field surface map saved to file: {fName}') plt.close() @@ -1077,11 +947,11 @@ def PlotMagSurface(PlanetList, Params): plt.tight_layout() if FigMisc.BASYM_WITH_SYM: fName = f'{Params.FigureFiles.MagSurfCombo[vCompMagSurf]}{tFnameEnd[iEval]}{FigMisc.xtn}' - fig.savefig(fName, bbox_inches='tight', format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(fName, bbox_inches='tight', format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Symmetric/asymmetric induced field surface maps saved to file: {fName}') else: fName = f'{Params.FigureFiles.MagSurfSym[vCompMagSurf]}{tFnameEnd[iEval]}{FigMisc.xtn}' - fig.savefig(fName, bbox_inches='tight', format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(fName, bbox_inches='tight', format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Symmetric induced field surface map saved to file: {fName}') plt.close() @@ -1122,7 +992,7 @@ def PlotMagSurface(PlanetList, Params): ax.set_aspect(1) plt.tight_layout() fName = f'{Params.FigureFiles.MagSurfDiff[vCompMagSurf]}{tFnameEnd[iEval]}{FigMisc.xtn}' - fig.savefig(fName, bbox_inches='tight', format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(fName, bbox_inches='tight', format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Induced field difference surface map saved to file: {fName}') plt.close() @@ -1169,7 +1039,7 @@ def PlotMagSurface(PlanetList, Params): ax.set_aspect(1) plt.tight_layout() fName = f'{Params.FigureFiles.MagSurfComp[vCompMagSurf]}{tFnameEnd[iEval]}{FigMisc.xtn}' - fig.savefig(fName, bbox_inches='tight', format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(fName, bbox_inches='tight', format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Induced field surface map saved to file: {fName}') plt.close() @@ -1413,7 +1283,7 @@ def PlotAsym(PlanetList, Params): ax.set_aspect(1) plt.tight_layout() fName = f'{Params.FigureFiles.asym}z{zMean_km:.1f}km{FigMisc.xtn}' - fig.savefig(fName, bbox_inches='tight', format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(fName, bbox_inches='tight', format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Asymmetric boundary surface map for z = {zMean_km:.1f} km saved to file: {fName}') plt.close() @@ -1498,7 +1368,7 @@ def PlotMagSpectrumInduced(PlanetList, Params): [ax.legend() for ax in axes] plt.tight_layout() - fig.savefig(Params.FigureFiles.MagFT, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(Params.FigureFiles.MagFT, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Magnetic Fourier spectra plot saved to file: {Params.FigureFiles.MagFT}') plt.close() @@ -1584,7 +1454,7 @@ def PlotMagSpectrum(PlanetList, Params): ax.legend() plt.tight_layout() - fig.savefig(Params.FigureFiles.MagFTexc, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(Params.FigureFiles.MagFTexc, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Magnetic excitation spectrum plot saved to file: {Params.FigureFiles.MagFTexc}') plt.close() @@ -1592,3 +1462,148 @@ def PlotMagSpectrum(PlanetList, Params): + +def extractInductionData(result_obj, inductOtype, i_valid): + """ + Extract data for inductogram phase space plotting based on inductOtype. + + This helper consolidates the complex data extraction logic that determines + what x/y data to use based on the inductOtype parameter. This logic block + appears in both PlotInductOgramPhaseSpace and potentially PlotInductOgram. + + Args: + result_obj: Result object with hierarchical structure (result.base.*, result.induction.*) + inductOtype: Type of induction plot ('sigma', 'oceanComp', 'Tb', etc.) + i_valid: Valid indices in 2D grid format + i_valid_flat: Valid indices in flattened format + + Returns: + dict: {'x_data': x_values, 'y_data': y_values, 'comp_list': compositions} + """ + + # Extract base data always needed + sigma_Sm = result_obj.base.sigmaMean_Sm[i_valid].flatten() + D_km = result_obj.base.D_km[i_valid].flatten() + comp_list = result_obj.base.oceanComp[i_valid].flatten() + + # Get axis data based on inductOtype + if inductOtype == 'sigma': + x_data = sigma_Sm + y_data = D_km + elif inductOtype == 'oceanComp': + # Convert ocean composition strings to numeric indices + x_raw = result_obj.base.oceanComp + x_data = np.array([]) + for i in range(x_raw.shape[0]): + row = np.repeat(int(i), x_raw.shape[1]) + x_data = np.concatenate((x_data, row)) + x_data = x_data.reshape(x_raw.shape) + x_data = x_data[i_valid].flatten() + # Use approximate ice thickness if available, otherwise regular ice thickness + y_data = result_obj.base.zb_approximate_km[i_valid].flatten() + else: + x_data = result_obj.xData[i_valid].flatten() + y_data = result_obj.yData[i_valid].flatten() + + return { + 'x_data': x_data, + 'y_data': y_data, + 'sigma_Sm': sigma_Sm, + 'D_km': D_km, + 'comp_list': comp_list + } + + +def setup_induction_coloring(result_obj, inductOtype, color_type, i_valid, i_valid_flat, + sigma_Sm, D_km, comp_list): + """ + Set up point coloring for inductogram phase space plots. + + This helper consolidates the complex coloring logic that appears in + PlotInductOgramPhaseSpace and potentially other magnetic plotting functions. + The coloring logic is quite complex with different branches for different + inductOtype and colorType combinations. + + Args: + result_obj: Result object with hierarchical structure + inductOtype: Type of induction plot ('sigma', 'oceanComp', etc.) + color_type: Type of coloring ('Tmean', 'zb', etc.) + i_valid: Valid indices in 2D grid format + i_valid_flat: Valid indices in flattened format + sigma_Sm: Extracted sigma values + D_km: Extracted D values + comp_list: Composition list for each point + + Returns: + np.array: Point colors for scatter plot + """ + + if inductOtype == 'sigma': + # In this case, we likely don't have salinity and ocean temp information + # so we need to set the colormap to use the info we do have + if hasattr(result_obj, 'sigmaMax') and hasattr(result_obj, 'bodyname'): + sigma_norm = sigma_Sm / 10**result_obj.sigmaMax[result_obj.bodyname] + else: + # Fallback normalization + sigma_norm = sigma_Sm / np.max(sigma_Sm) if len(sigma_Sm) > 0 else np.array([0.5]) + + D_norm = D_km / np.max(D_km) if len(D_km) > 1 else np.ones_like(D_km) * 0.5 + pt_colors = Color.OceanCmap(comp_list, sigma_norm, D_norm, + DARKEN_SALINITIES=FigMisc.DARKEN_SALINITIES) + + elif inductOtype == 'oceanComp': + # For oceanComp, use a different coloring scheme + # Color by ice shell thickness or another appropriate variable + if color_type == 'Tmean': + cbar_data = result_obj.base.Tmean_K[i_valid].flatten() + else: + cbar_data = result_obj.base.zb_km[i_valid].flatten() + + if np.size(cbar_data) > 1: + cbar_norm = interp1d([np.min(cbar_data), np.max(cbar_data)], [0.0, 1.0])(cbar_data) + else: + cbar_norm = np.ones_like(cbar_data) * 0.5 + + # Use a simple color scheme for ocean compositions + pt_colors = Color.OceanCmap(comp_list, cbar_norm, cbar_norm, + DARKEN_SALINITIES=FigMisc.DARKEN_SALINITIES) + + else: + # Standard coloring for other inductOtypes + Tmean_K = result_obj.base.Tmean_K[i_valid].flatten() + + if FigMisc.NORMALIZED_SALINITIES: + wMax_ppt = np.array([Color.saturation[comp] for comp in comp_list]) + if hasattr(result_obj, 'x') and np.size(result_obj.x[i_valid]) > 0: + w_normFrac = result_obj.x[i_valid].flatten() / wMax_ppt + else: + w_normFrac = np.ones_like(Tmean_K) * 0.5 + else: + x_vals = result_obj.xData[i_valid].flatten() + if np.max(x_vals) > np.min(x_vals): + w_normFrac = interp1d([np.min(x_vals), np.max(x_vals)], [0.0, 1.0])(x_vals) + else: + w_normFrac = np.ones_like(x_vals) * 0.5 + + if color_type == 'Tmean': + if FigMisc.NORMALIZED_TEMPERATURES: + Tmean_normFrac = Color.GetNormT(Tmean_K) + else: + if np.max(Tmean_K) > np.min(Tmean_K): + Tmean_normFrac = interp1d([np.min(Tmean_K), np.max(Tmean_K)], [0.0, 1.0])(Tmean_K) + else: + Tmean_normFrac = np.ones_like(Tmean_K) * 0.5 + pt_colors = Color.OceanCmap(comp_list, w_normFrac, Tmean_normFrac, + DARKEN_SALINITIES=FigMisc.DARKEN_SALINITIES) + elif color_type == 'zb': + zb_km = result_obj.base.zb_km[i_valid].flatten() + if np.max(zb_km) > np.min(zb_km): + zb_normFrac = interp1d([np.min(zb_km), np.max(zb_km)], [0.0, 1.0])(zb_km) + else: + zb_normFrac = np.ones_like(zb_km) * 0.5 + pt_colors = Color.OceanCmap(comp_list, w_normFrac, zb_normFrac, + DARKEN_SALINITIES=FigMisc.DARKEN_SALINITIES) + else: + raise ValueError(f'Inductogram colortype {color_type} not recognized.') + + return pt_colors diff --git a/PlanetProfile/Plotting/MontePlots.py b/PlanetProfile/Plotting/MontePlots.py new file mode 100644 index 00000000..7948fe07 --- /dev/null +++ b/PlanetProfile/Plotting/MontePlots.py @@ -0,0 +1,492 @@ +import numpy as np +import logging +from copy import deepcopy +from collections.abc import Iterable +import matplotlib.pyplot as plt +import matplotlib.colorbar as mcbar +import matplotlib.ticker as ticker +from matplotlib.gridspec import GridSpec +from matplotlib.patches import Rectangle +from mpl_toolkits.axes_grid1 import make_axes_locatable +from scipy.interpolate import interp1d +from PlanetProfile.GetConfig import Color, Style, FigLbl, FigSize, FigMisc +from PlanetProfile.Utilities.defineStructs import xyzComps, vecComps +from PlanetProfile.MagneticInduction.Moments import Excitations as Mag +from MoonMag.asymmetry_funcs import getMagSurf as GetMagSurf + +log = logging.getLogger('PlanetProfile') + +def PlotMonteCarloResults(results_list, Params): + """ + Plots histograms of output parameters from Monte Carlo or exploration analysis. + Uses user-defined output parameters and works with both Monte Carlo and exploration structures. + + Args: + results_list: List of MonteCarloResults or ExplorationResults objects + Params: Configuration parameters with optional outputParams attribute + """ + + # Define default output parameters - user can override via Params.outputParams + default_output_params = ['CMR2calc', 'Mtot_kg', 'kLoveAmp', 'hLoveAmp', 'zb_km'] + output_params = getattr(Params, 'outputParams', default_output_params) + + # Process each result + for result in results_list: + + # Get valid mask - assume it exists if needed + valid_mask = result.base.VALID + + # Check if we should plot by ocean composition + plot_by_ocean_comp = (result.base.oceanComp is not None and + Params.MonteCarlo.plotOceanComps) + + # Set up subplot grid + n_params = len(output_params) + n_cols = 4 + n_rows = int(np.ceil(n_params / n_cols)) + + fig, axes = plt.subplots(n_rows, n_cols, figsize=(16, 4*n_rows)) + if n_rows == 1: + axes = axes.reshape(1, -1) + + fig.suptitle(f'Output Parameter Distributions - {result.bodyname}', fontsize=16) + + # Ocean composition setup if needed + if plot_by_ocean_comp: + valid_ocean_comps = result.base.oceanComp[valid_mask] + unique_comps = np.unique(valid_ocean_comps) + + # Set up color mapping + ocean_comp_list = result.base.oceanComp + if isinstance(ocean_comp_list, np.ndarray): + ocean_comp_list = ocean_comp_list.tolist() + + cmap = plt.cm.coolwarm + + # Plot each output parameter + for i, param_name in enumerate(output_params): + row, col = i // n_cols, i % n_cols + ax = axes[row, col] + + # Extract parameter values from base structure + values = getattr(result.base, param_name) + + # Apply valid mask + valid_values = values[valid_mask] + + if len(valid_values) > 0 and np.any(np.isfinite(valid_values)): + if plot_by_ocean_comp: + # Plot as jittered dots grouped by ocean composition + all_valid_values = valid_values[~np.isnan(valid_values)] + _, bin_edges = np.histogram(all_valid_values, bins=20) + bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2 + + for j, comp in enumerate(unique_comps): + comp_mask = valid_ocean_comps == comp + comp_values = valid_values[comp_mask] + + if len(comp_values) > 0: + comp_values_clean = comp_values[~np.isnan(comp_values)] + hist, _ = np.histogram(comp_values_clean, bins=bin_edges) + + # Set color based on composition order + try: + comp_index = ocean_comp_list.index(comp) + norm_val = comp_index / (len(ocean_comp_list) - 1) if len(ocean_comp_list) > 1 else 0.5 + this_color = cmap(norm_val) + except ValueError: + norm_val = j / (len(unique_comps) - 1) if len(unique_comps) > 1 else 0.5 + this_color = cmap(norm_val) + + # Plot jittered dots for each bin + for k, (center, count) in enumerate(zip(bin_centers, hist)): + if count > 0: + # Handle composition label formatting + comp_label = format_composition_label(comp) + + # Create jittered scatter points + x_jittered = center + np.random.normal(0, (bin_edges[1] - bin_edges[0]) * 0.05, count) + y_jittered = np.ones(count) * count + np.random.normal(0, 0.15, count) + + ax.scatter(x_jittered, y_jittered, + color=this_color, alpha=0.7, s=25, + label=f'{comp_label}' if k == 0 else "", + marker='o') + + ax.set_ylabel('Count') + ax.yaxis.set_major_locator(plt.MaxNLocator(integer=True)) + ax.set_title(f'{param_name} by Ocean Composition') + + # Add legend only to first subplot + if i == 0 and Params.LEGEND: + ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left', fontsize='small') + + else: + # Plot as regular histogram + ax.hist(valid_values, bins=30, alpha=0.7, density=True, edgecolor='black') + ax.set_ylabel('Density') + ax.set_title(f'{param_name} Distribution') + + ax.set_xlabel(param_name) + ax.grid(True, alpha=0.3) + else: + # No valid data for this parameter + ax.text(0.5, 0.5, f'No valid data\nfor {param_name}', + transform=ax.transAxes, ha='center', va='center') + ax.set_xlabel(param_name) + ax.set_ylabel('Density' if not plot_by_ocean_comp else 'Count') + ax.set_title(f'{param_name} Distribution') + + # Hide unused subplots + for i in range(n_params, n_rows * n_cols): + row, col = i // n_cols, i % n_cols + axes[row, col].set_visible(False) + + # Save the plot + plt.tight_layout() + + # Save to Monte Carlo results file - works for both result types + fig.savefig(Params.FigureFiles.montecarloResults, format=FigMisc.figFormat, + dpi=FigMisc.dpi, metadata=FigLbl.meta) + log.info(f'Output parameter distributions plot saved to: {Params.FigureFiles.montecarloResults}') + + plt.close() + + +def PlotMonteCarloDistributions(results_list, Params): + """ + Modern implementation of PlotMonteCarloDistributions using streamlined architecture. + + Plots histograms of parameter distributions from Monte Carlo analysis. + Uses the same single-loop architecture as exploration functions. + + Args: + results_list: List of MonteCarloResults objects + Params: Configuration parameters + """ + + # Process each Monte Carlo result + for mc_result in results_list: + + # Skip if no successful models + if mc_result.base.nSuccess == 0: + log.warning(f'No successful models to plot for {mc_result.bodyname}') + continue + + # Get parameter information + n_params = len(mc_result.base.paramsToSearch) + n_cols = 4 + n_rows = int(np.ceil(n_params / n_cols)) + + # Create figure and subplots + fig, axes = plt.subplots(n_rows, n_cols, figsize=(16, 4*n_rows)) + if n_rows == 1: + axes = axes.reshape(1, -1) + + fig.suptitle(f'Monte Carlo Parameter Distributions - {mc_result.bodyname}', fontsize=16) + + # Plot each parameter distribution + for i, param in enumerate(mc_result.base.paramsToSearch): + row, col = i // n_cols, i % n_cols + ax = axes[row, col] + + # Extract parameter values using hierarchical structure + values = mc_result.base.paramValues[param] + valid_values = values[mc_result.base.VALID] + + # Plot all samples with histogram + ax.hist(values, bins=30, alpha=0.5, label='All samples', density=True) + + # Add styling and labels + ax.set_xlabel(param) + ax.set_ylabel('Density') + ax.set_title(f'{param} Distribution') + ax.legend() + ax.grid(True, alpha=0.3) + + # Hide unused subplots + for i in range(n_params, n_rows * n_cols): + row, col = i // n_cols, i % n_cols + axes[row, col].set_visible(False) + + # Save the plot + plt.tight_layout() + fig.savefig(Params.FigureFiles.montecarloDistributions, format=FigMisc.figFormat, + dpi=FigMisc.dpi, metadata=FigLbl.meta) + log.info(f'Monte Carlo distributions plot saved to: {Params.FigureFiles.montecarloDistributions}') + plt.close() + + +def PlotMonteCarloScatter(results_list, Params): + """ + Modern implementation of PlotMonteCarloScatter using streamlined architecture. + + Plots scatter plots of Monte Carlo parameter pairs with optional ocean composition coloring + and ice thickness highlighting. Features zoom inset when highlighting ice thickness. + + Args: + results_list: List of MonteCarloResults objects + Params: Configuration parameters + """ + # Process each Monte Carlo result + for result in results_list: + + # Skip if no scatter parameters specified + if not Params.MonteCarlo.scatterParams: + log.warning('No scatter parameters specified in Params.MonteCarlo.scatterParams.') + continue + + # Get valid mask + if Params.ALLOW_BROKEN_MODELS: + log.warning('Params.ALLOW_BROKEN_MODELS is True. Will plot all models, not just successful ones.') + valid_mask = np.ones_like(result.base.CMR2calc, dtype=bool) + else: + valid_mask = result.base.VALID + + # Check if we should color by ocean composition + color_by_ocean_comp = (result.base.oceanComp is not None and + Params.MonteCarlo.plotOceanComps) + + # Get available excitations using the helper function + available_excitations = get_available_excitations(result) + + # Process scatter parameter pairs + scatter_pairs = Params.MonteCarlo.scatterParams + if not isinstance(scatter_pairs[0], list): + scatter_pairs = [scatter_pairs] + + # Build plot_info with proper excitation expansion + plot_info = [] + for x_param, y_param in scatter_pairs: + # Check if parameters are magnetic and need excitation expansion + x_is_magnetic = x_param in ['Bix_nT', 'Biy_nT', 'Biz_nT', 'Bi_nT', 'phase'] + y_is_magnetic = y_param in ['Bix_nT', 'Biy_nT', 'Biz_nT', 'Bi_nT', 'phase'] + + if (x_is_magnetic or y_is_magnetic) and available_excitations: + # Create plots for each available excitation + for exc_name in available_excitations: + x_exc = exc_name if x_is_magnetic else None + y_exc = exc_name if y_is_magnetic else None + plot_info.append((x_param, y_param, x_exc, y_exc)) + else: + # Non-magnetic parameters or no excitations available + plot_info.append((x_param, y_param, None, None)) + + total_plots = len(plot_info) + + if total_plots == 0: + log.warning('No valid parameter combinations found for scatter plots.') + continue + + # Set up subplot grid + n_cols = min(2, total_plots) + n_rows = int(np.ceil(total_plots / n_cols)) + + fig, axes = plt.subplots(n_rows, n_cols, figsize=(5*n_cols, 4*n_rows)) + + # Ensure axes is always 2D array for consistent indexing + if n_rows == 1 and n_cols == 1: + axes = np.array([[axes]]) + elif n_rows == 1: + axes = axes.reshape(1, -1) + elif n_cols == 1: + axes = axes.reshape(-1, 1) + + # Set main title + if color_by_ocean_comp: + fig.suptitle(f'Monte Carlo Parameter Scatter Plots by Ocean Composition - {result.bodyname}', + fontsize=14) + else: + fig.suptitle(f'Monte Carlo Parameter Scatter Plots - {result.bodyname}', fontsize=14) + + # Create scatter plots + for plot_idx, (x_param, y_param, x_exc, y_exc) in enumerate(plot_info): + row, col = plot_idx // n_cols, plot_idx % n_cols + ax = axes[row, col] + + # Extract parameter values using helper (pass full result for hierarchical structure) + x_values = extract_monte_carlo_parameter_values(result, x_param, x_exc, valid_mask) + y_values = extract_monte_carlo_parameter_values(result, y_param, y_exc, valid_mask) + + # Apply valid mask and remove NaN values + valid_data_mask = valid_mask & np.isfinite(x_values) & np.isfinite(y_values) + x_plot = x_values[valid_data_mask] + y_plot = y_values[valid_data_mask] + + if len(x_plot) == 0: + ax.text(0.5, 0.5, 'No valid data', transform=ax.transAxes, ha='center', va='center') + ax.set_xlabel(x_param) + ax.set_ylabel(y_param) + continue + + # Check if we should highlight specific ice thicknesses + highlight_ice_thickness = (FigMisc.HIGHLIGHT_ICE_THICKNESSES and + result.base.zb_km is not None) + + highlight_mask = None + if highlight_ice_thickness: + # Get ice thickness data for valid points + ice_thickness_plot = result.base.zb_km[valid_data_mask] + + # Determine which points to highlight + highlight_mask = np.zeros(len(x_plot), dtype=bool) + for target_thickness in FigMisc.ICE_THICKNESSES_TO_SHOW: + thickness_mask = np.abs(ice_thickness_plot - target_thickness) <= FigMisc.ICE_THICKNESS_TOLERANCE + highlight_mask |= thickness_mask + + dimmed_mask = ~highlight_mask + + # Plot data based on coloring mode + if color_by_ocean_comp: + # Group by unique ocean compositions (maintaining order) and plot connecting lines + uniqueComps, indices = np.unique(all_ocean_comps, return_index=True) + uniqueComps = uniqueComps[np.argsort(indices)] + + # Track plotted dimmed coordinates to prevent overlapping brightness + plotted_dimmed_coords = set() + + for comp in uniqueComps: + comp_mask = result.base.oceanComp[valid_data_mask] == comp + if not np.any(comp_mask): + continue + + comp_label = format_composition_label(comp) + color = Color.cmap[comp](0.5) + + if highlight_ice_thickness: + # Plot highlighted points (higher alpha) + highlight_comp_mask = comp_mask & highlight_mask + if np.any(highlight_comp_mask): + ax.scatter(x_plot[highlight_comp_mask], y_plot[highlight_comp_mask], + color=color, alpha=0.7, s=FigMisc.SCATTER_DOT_SIZE, + label=comp_label, edgecolors='black', linewidth=0.5, zorder=3) + + # Plot dimmed points (lower alpha) - but only if not already plotted + dimmed_comp_mask = comp_mask & dimmed_mask + if np.any(dimmed_comp_mask): + # Filter out coordinates that have already been plotted + dimmed_x = x_plot[dimmed_comp_mask] + dimmed_y = y_plot[dimmed_comp_mask] + + # Check which points haven't been plotted yet + unplotted_mask = np.array([ + (round(x, 6), round(y, 6)) not in plotted_dimmed_coords + for x, y in zip(dimmed_x, dimmed_y) + ]) + + if np.any(unplotted_mask): + # Plot only unplotted points + unplotted_x = dimmed_x[unplotted_mask] + unplotted_y = dimmed_y[unplotted_mask] + + ax.scatter(unplotted_x, unplotted_y, + color=color, alpha=FigMisc.DIMMED_ALPHA, s=FigMisc.SCATTER_DOT_SIZE, + edgecolors='black', linewidth=0.5, zorder=1) + + # Add these coordinates to the plotted set + for x, y in zip(unplotted_x, unplotted_y): + plotted_dimmed_coords.add((round(x, 6), round(y, 6))) + else: + ax.scatter(x_plot[comp_mask], y_plot[comp_mask], + color=color, alpha=0.7, s=FigMisc.SCATTER_DOT_SIZE, + label=comp_label, edgecolors='black', linewidth=0.5) + + # Add legend only to first subplot + if plot_idx == 0 and Params.LEGEND: + ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left', fontsize='small') + + else: + # Standard scatter plot + if highlight_ice_thickness: + # Plot highlighted points (higher alpha) + if np.any(highlight_mask): + ax.scatter(x_plot[highlight_mask], y_plot[highlight_mask], + alpha=0.7, s=FigMisc.SCATTER_DOT_SIZE, edgecolors='black', + linewidth=0.5, zorder=3, + label=f'Ice thickness: {FigMisc.ICE_THICKNESSES_TO_SHOW} ± {FigMisc.ICE_THICKNESS_TOLERANCE} km' if plot_idx == 0 else None) + + # Plot dimmed points (lower alpha) + if np.any(dimmed_mask): + ax.scatter(x_plot[dimmed_mask], y_plot[dimmed_mask], + alpha=FigMisc.DIMMED_ALPHA, s=FigMisc.SCATTER_DOT_SIZE, + edgecolors='black', linewidth=0.5, zorder=1, + label='Other ice thicknesses' if plot_idx == 0 else None) + + # Add legend for ice thickness highlighting + if plot_idx == 0 and Params.LEGEND: + ax.legend(fontsize='small') + + else: + ax.scatter(x_plot, y_plot, alpha=0.7, s=FigMisc.SCATTER_DOT_SIZE, + edgecolors='black', linewidth=0.5) + + # Create zoom inset if highlighting ice thickness + if highlight_ice_thickness and np.any(highlight_mask) and FigMisc.DO_SCATTER_INSET: + inset_ax, optimal_location = create_zoom_inset(ax, x_plot, y_plot, highlight_mask) + + if inset_ax is not None: + # Plot the same data in the inset + if color_by_ocean_comp: + comp_plot = result.base.oceanComp[valid_data_mask] + for comp in uniqueComps: + comp_mask = comp_plot == comp + if np.any(comp_mask): + color = Color.cmap[comp](0.5) + + # Only plot highlighted points in zoom + highlight_comp_mask = comp_mask & highlight_mask + if np.any(highlight_comp_mask): + inset_ax.scatter(x_plot[highlight_comp_mask], y_plot[highlight_comp_mask], + color=color, alpha=0.7, s=FigMisc.SCATTER_DOT_SIZE*0.7, + edgecolors='black', linewidth=0.3) + else: + inset_ax.scatter(x_plot[highlight_mask], y_plot[highlight_mask], + alpha=0.7, s=FigMisc.SCATTER_DOT_SIZE*0.7, + edgecolors='black', linewidth=0.3) + else: + inset_ax = None + + # Set axis labels + if x_param in ['Bix_nT', 'Biy_nT', 'Biz_nT', 'Bi_nT', 'phase']: + ax.set_xlabel(x_param + '_' + x_exc) + else: + ax.set_xlabel(x_param) + if y_param in ['Bix_nT', 'Biy_nT', 'Biz_nT', 'Bi_nT', 'phase']: + ax.set_ylabel(y_param + '_' + y_exc) + else: + ax.set_ylabel(y_param) + + + + + axList = [ax] if inset_ax is None else [ax, inset_ax] + for a in axList: + # Set up grid with configurable spacing + major_x_spacing = 0.12 + major_y_spacing = 12 + minor_x_spacing = 0.04 + minor_y_spacing = 3 + if a.get_ylim()[1] - a.get_ylim()[0] < major_y_spacing: + major_y_spacing = minor_y_spacing * 2 + if a.get_xlim()[1] - a.get_xlim()[0] < major_x_spacing: + major_x_spacing = minor_x_spacing * 2 + a.xaxis.set_major_locator(ticker.MultipleLocator(major_x_spacing)) + a.yaxis.set_major_locator(ticker.MultipleLocator(major_y_spacing)) + a.xaxis.set_minor_locator(ticker.MultipleLocator(minor_x_spacing)) + a.yaxis.set_minor_locator(ticker.MultipleLocator(minor_y_spacing)) + a.grid(which = 'minor', alpha = 0.3) + a.grid(which = 'major', alpha = 0.5) + + # Hide unused subplots + if total_plots < n_rows * n_cols: + for i in range(total_plots, n_rows * n_cols): + row, col = i // n_cols, i % n_cols + axes[row, col].set_visible(False) + + plt.tight_layout() + + # Save plot + scatter_file_name = Params.FigureFiles.montecarloResults.replace('_results', '_scatter') + fig.savefig(scatter_file_name, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + log.info(f'Monte Carlo scatter plots saved to: {scatter_file_name}') + plt.close() \ No newline at end of file diff --git a/PlanetProfile/Plotting/PTPlots.py b/PlanetProfile/Plotting/PTPlots.py index d4d3b4d8..01b2aa09 100644 --- a/PlanetProfile/Plotting/PTPlots.py +++ b/PlanetProfile/Plotting/PTPlots.py @@ -5,16 +5,14 @@ import matplotlib.pyplot as plt from matplotlib.gridspec import GridSpec from matplotlib.axes import Axes -from matplotlib.colors import LinearSegmentedColormap as DiscreteCmap, to_rgb, BoundaryNorm -from matplotlib.colors import TwoSlopeNorm -from scipy.interpolate import interp1d +from matplotlib.colors import LinearSegmentedColormap as DiscreteCmap, to_rgb, BoundaryNorm, ListedColormap, TwoSlopeNorm from PlanetProfile.GetConfig import Color, Style, FigLbl, FigSize, FigMisc from PlanetProfile.Thermodynamics.HydroEOS import GetOceanEOS, GetIceEOS from PlanetProfile.Utilities.Indexing import PhaseConv, PhaseInv, GetPhaseIndices from PlanetProfile.Thermodynamics.InnerEOS import GetInnerEOS from PlanetProfile.Utilities.defineStructs import Constants from typing import Optional, List - +import reaktoro as rkt import itertools import copy @@ -35,10 +33,18 @@ def PlotHydrosphereSpecies(PlanetList, Params): if not Planet.Do.NO_H2O and 'CustomSolution' in Planet.Ocean.comp: fig = plt.figure(figsize=FigSize.vhydroSpecies) grid = GridSpec(4, 2) - allspeciesax = fig.add_subplot(grid[0:3, 0]) - aqueouspseciesax = fig.add_subplot(grid[0:3, 1]) + # Get supcrt database to check phase type + supcrt_database = rkt.SupcrtDatabase(Params.CustomSolution.SUPCRT_DATABASE) + speciesPhase = [supcrt_database.species(species).aggregateState() for species in Planet.Ocean.aqueousSpecies] + if np.any([speciesPhase[i] == rkt.AggregateState.Solid for i in range(len(speciesPhase))]): + solidAx = True + solidspeciesax = fig.add_subplot(grid[0:3, 0]) + aqueousSpeciesAx = fig.add_subplot(grid[0:3, 1]) + else: + solidAx = False + aqueousSpeciesAx = fig.add_subplot(grid[0:3, 0:2]) # If we have a reaction with affinities to plot, then we should split the second axis into two columns - plot_reaction_marker = Planet.Ocean.reaction != "NaN" + plot_reaction_marker = Planet.Ocean.Reaction.reaction != "NaN" if plot_reaction_marker: pHax = fig.add_subplot(grid[3, 0]) affinityax = fig.add_subplot(grid[3, 1]) @@ -47,13 +53,18 @@ def PlotHydrosphereSpecies(PlanetList, Params): # If not, then just plot pH else: pHax = fig.add_subplot(grid[3, :]) - axs = [allspeciesax, aqueouspseciesax] - if Style.GRIDS: - allspeciesax.grid() - allspeciesax.set_axisbelow(True) - allspeciesax.set_xlabel(FigLbl.allOceanSpeciesLabel) - allspeciesax.set_ylabel(FigLbl.zLabel) - aqueouspseciesax.set_xlabel(FigLbl.aqueousSpeciesLabel) + if solidAx: + axs = [solidspeciesax, aqueousSpeciesAx] + solidspeciesax.set_xlabel(FigLbl.solidSpeciesLabel) + solidspeciesax.set_ylabel(FigLbl.zLabel) + else: + axs = [aqueousSpeciesAx] + aqueousSpeciesAx.set_ylabel(FigLbl.zLabel) + aqueousSpeciesAx.set_xlabel(FigLbl.aqueousSpeciesLabel) + for ax in axs: + if Style.GRIDS: + ax.grid() + ax.set_axisbelow(True) pHax.set_xlabel(FigLbl.zLabel) pHax.set_ylabel(FigLbl.pHLabel) @@ -65,67 +76,104 @@ def PlotHydrosphereSpecies(PlanetList, Params): indsFe = GetPhaseIndices(Planet.phase) # If we have liquid indices then let's plot hydrosphere species if np.size(indsLiq) != 0: + aqueousSpecies = Planet.Ocean.aqueousSpecies.copy() + allSpeciesData = Planet.Ocean.aqueousSpeciesAmount_mol.copy() + # Set overall figure title if Params.TITLES: fig.suptitle( - f'{PlanetList[0].name}{FigLbl.hydroSpeciesTitle}') - # Get all relevant species to plot and their speciation - relevant_species_to_plot = [] - relevant_indices_of_species_to_plot = [] - for index, value in enumerate(Planet.Ocean.aqueousSpecies): - if value not in FigMisc.excludeSpeciesFromHydrospherePlot: - relevant_species_to_plot.append(value) - relevant_indices_of_species_to_plot.append(index) - relevant_species_amount_to_plot = Planet.Ocean.aqueousSpeciesAmount_mol[relevant_indices_of_species_to_plot] + f'{PlanetList[0].bodyname} {PlanetList[0].label},{FigLbl.hydroSpeciesTitle}') ocean_depth = Planet.z_m[indsLiq] + # Go through each species and plot - for i, species in enumerate(relevant_species_to_plot): - speciesAmountData = relevant_species_amount_to_plot[i] - # Plot species labels - But only if they are above FigMisc.minThreshold - indices_above_min_threshold = np.where(speciesAmountData > FigMisc.minThreshold)[0] - x_label_pos = speciesAmountData[indices_above_min_threshold[0]] if ( - indices_above_min_threshold.size > 0) else -1 - if x_label_pos >= 0: - species_phase = '' - if any(aqueousSpeciesToPlot in species for aqueousSpeciesToPlot in - FigMisc.aqueousSpeciesLabels): - species_phase = 'aqueous' - elif any(aqueousSpeciesToPlot in species for aqueousSpeciesToPlot in - FigMisc.gasSpeciesLabels): - species_phase = 'gas' - else: - species_phase = 'solid' - if FigMisc.TEX_INSTALLED: - species_name = re.sub(r'(\w)(\+|\-)(\d+)', r'\1^{\3\2}', species) - species_label = rf"$\ce{{{species_name}}}$" - else: - species_label = species + text_objects = [] # Store text objects for adjust_text if available + + + for i, species in enumerate(aqueousSpecies): + speciesAmountData = allSpeciesData[i] + species_phase = '' + if supcrt_database.species(species).aggregateState() == rkt.AggregateState.Aqueous: + species_phase = 'aqueous' + elif supcrt_database.species(species).aggregateState() == rkt.AggregateState.Gas: + species_phase = 'gas' + else: + species_phase = 'solid' + if FigMisc.TEX_INSTALLED: + species_name = re.sub(r'(\w)(\+|\-)(\d+)', r'\1^{\3\2}', species) + species_label = rf"$\ce{{{species_name}}}$" + else: + species_label = species + # Plot species labels - But only if they are above FigMisc.minVolSolidThreshold_cm3 + if species_phase == 'solid': + indices_above_min_threshold = np.where(speciesAmountData > FigMisc.minVolSolidThreshold_cm3)[0] + else: + # Plot species labels - But only if they are above FigMisc.minThreshold + indices_above_min_threshold = np.where(speciesAmountData > FigMisc.minAqueousThreshold)[0] + + if indices_above_min_threshold.size > 0 and species not in FigMisc.excludeSpeciesFromHydrospherePlot: + indexPosition = indices_above_min_threshold[(i*4) % len(indices_above_min_threshold)] + x_label_pos = speciesAmountData[indexPosition] + y_label_pos = ocean_depth[indexPosition] / 1e3 style = Style.LS_hydroSpecies[species_phase] linewidth = Style.LW_hydroSpecies[species_phase] - color = Color.cmap['hydroSpecies'](i % len(relevant_species_to_plot)) - line, = allspeciesax.plot(speciesAmountData, ocean_depth / 1e3, linestyle=style, color=color, linewidth = linewidth) - y_label_pos = ocean_depth[ - (i*3) % len(ocean_depth)] / 1e3 # y position of the end of the line - allspeciesax.text(x_label_pos, y_label_pos, species_label, - color=line.get_color(), - verticalalignment='bottom', - horizontalalignment='right', - fontsize=FigLbl.speciesSize) - if any(aqueousSpeciesToPlot in species for aqueousSpeciesToPlot in - FigMisc.aqueousSpeciesLabels): - aqueouspseciesax.plot(speciesAmountData, ocean_depth / 1e3, linestyle=style, color=color, linewidth = linewidth) - if x_label_pos >= 0: - y_label_pos = ocean_depth[(i * 3) % len( - ocean_depth)] / 1e3 # y position of the end of the line - aqueouspseciesax.text(x_label_pos, y_label_pos, species_label, - color=line.get_color(), - verticalalignment='bottom', - horizontalalignment='right', - fontsize=FigLbl.speciesSize) + cmap = Color.cmap['hydroSpecies'] + if isinstance(cmap, ListedColormap): + nColors = len(cmap.colors) + color = Color.cmap['hydroSpecies'](i % nColors) + else: + color = Color.cmap['hydroSpecies'](i % len(aqueousSpecies)) / len(aqueousSpecies) + if species_phase == 'solid': + line, = solidspeciesax.plot(speciesAmountData, ocean_depth / 1e3, linestyle=style, color=color, linewidth = linewidth) + # Add text to all species axis + text_obj = solidspeciesax.text(x_label_pos, y_label_pos, species_label, + color=line.get_color(), + verticalalignment='bottom', + horizontalalignment='center', + fontsize=FigLbl.speciesSize, bbox=dict(boxstyle='round,pad=0.2', facecolor='white', alpha=0.5, edgecolor='none'), zorder=10) + text_objects.append(text_obj) + elif species_phase == 'aqueous': + line, = aqueousSpeciesAx.plot(speciesAmountData, ocean_depth / 1e3, linestyle=style, color=color, linewidth = linewidth) + if x_label_pos >= 0: + aqueousSpeciesAx.text(x_label_pos, y_label_pos, species_label, + color=line.get_color(), + verticalalignment='bottom', + horizontalalignment='center', + fontsize=FigLbl.speciesSize, + bbox=dict(boxstyle='round,pad=0.2', facecolor='white', alpha=0.5, edgecolor='none'), zorder=10) + """if Planet.Ocean.Reaction.reaction != 'NaN': + for species in Planet.Ocean.Reaction.disequilibriumConcentrations.keys(): + if not Planet.Ocean.Reaction.useReferenceSpecies: + speciesDisequilibriumAmount = Planet.Ocean.Reaction.disequilibriumConcentrations[species] + speciesIndex = np.where(Planet.Ocean.aqueousSpecies == species)[0][0] + speciesEquilibriumAmount = Planet.Ocean.aqueousSpeciesAmount_mol[speciesIndex] + if speciesAmountData is None: + speciesAmountData = speciesEquilibriumAmount + else: + speciesAmountData = np.repeat(speciesDisequilibriumAmount, len(speciesAmountData)) + else: + referenceSpecies = Planet.Ocean.Reaction.referenceSpecies + referenceSpeciesIndex = np.where(Planet.Ocean.aqueousSpecies == referenceSpecies)[0][0] + referenceSpeciesAmount = Planet.Ocean.aqueousSpeciesAmount_mol[referenceSpeciesIndex] + speciesRatioToReferenceSpecies = Planet.Ocean.Reaction.disequilibriumConcentrations[species] + if speciesRatioToReferenceSpecies is None: + speciesIndex = np.where(Planet.Ocean.aqueousSpecies == species)[0][0] + speciesAmountData = Planet.Ocean.aqueousSpeciesAmount_mol[speciesIndex] + else: + speciesAmountData = speciesRatioToReferenceSpecies * referenceSpeciesAmount + style = Style.LS_hydroSpeciesDisequilibrium + linewidth = Style.LW_hydroSpeciesDisequilibrium + cmap = Color.cmap['hydroSpecies'] + line = aqueousSpeciesAx.plot(speciesAmountData, ocean_depth / 1e3, linestyle=style, color=color, linewidth = linewidth) + """ + + for ax in axs: ax.set_xscale('log') current_xlim = ax.get_xlim() - new_xmin = max(current_xlim[0], FigMisc.minThreshold) + if solidAx and ax == solidspeciesax: + new_xmin = max(current_xlim[0], FigMisc.minVolSolidThreshold_cm3) + else: + new_xmin = max(current_xlim[0], FigMisc.minAqueousThreshold) new_xmax = 10 ** np.ceil(np.log10(current_xlim[1])) ax.set_xlim([new_xmin, new_xmax]) ax.invert_yaxis() @@ -136,9 +184,9 @@ def PlotHydrosphereSpecies(PlanetList, Params): # If we should plot reaction, then let's plot reaction pHs and affinity if plot_reaction_marker: if FigMisc.TEX_INSTALLED: - reaction_label = rf"$\ce{{{Planet.Ocean.reaction}}}$" + reaction_label = rf"$\ce{{{Planet.Ocean.Reaction.reaction}}}$" else: - reaction_label = Planet.Ocean.reaction + reaction_label = Planet.Ocean.Reaction.reaction affinityax.plot(ocean_depth[bulk_pH_not_nan] / 1e3, Planet.Ocean.affinity_kJ[bulk_pH_not_nan], linestyle ='-', color = 'black', label = reaction_label) if Params.LEGEND: @@ -146,8 +194,7 @@ def PlotHydrosphereSpecies(PlanetList, Params): affinityax.legend(handles, lbls, fontsize = 5) plt.tight_layout() - fig.savefig(Params.FigureFiles.hydroSpecies, format=FigMisc.figFormat, dpi=FigMisc.dpi, - metadata=FigLbl.meta) + fig.savefig(Params.FigureFiles.hydroSpecies, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Ocean aqueous species plot saved to file: {Params.FigureFiles.hydroSpecies}') plt.close() else: @@ -214,7 +261,9 @@ def PlotHydroPhase(PlanetList, Params): Planet.Ocean.MgSO4elecType, rhoType=Planet.Ocean.MgSO4rhoType, scalingType=Planet.Ocean.MgSO4scalingType, FORCE_NEW=Params.FORCE_EOS_RECALC, phaseType=Planet.Ocean.phaseType, EXTRAP=Params.EXTRAP_OCEAN, - sigmaFixed_Sm=Planet.Ocean.sigmaFixed_Sm, LOOKUP_HIRES=Planet.Do.OCEAN_PHASE_HIRES) + sigmaFixed_Sm=Planet.Ocean.sigmaFixed_Sm, LOOKUP_HIRES=Planet.Do.OCEAN_PHASE_HIRES, kThermConst_WmK=Planet.Ocean.kThermWater_WmK, + propsStepReductionFactor=Planet.Ocean.propsStepReductionFactor) + phases = oceanEOS.fn_phase(P_MPa, T_K, grid=True).astype(int) new_ices = set([PhaseConv(ice) for ice in np.unique(np.append(phases[phases != 0], 1))]) if not new_ices.issubset(ices): @@ -224,7 +273,7 @@ def PlotHydroPhase(PlanetList, Params): phiTop_frac=Planet.Ocean.phiMax_frac[ice], Pclosure_MPa=Planet.Ocean.Pclosure_MPa[ice], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE[ice], - ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT) + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, kThermConst_WmK=Planet.Ocean.kThermIce_WmK) for ice in ices} # Add clathrates to phase and property diagrams where it is stable (if modeled) if Planet.Do.CLATHRATE: @@ -242,9 +291,9 @@ def PlotHydroPhase(PlanetList, Params): phiTop_frac=Planet.Ocean.phiMax_frac[clath], Pclosure_MPa=Planet.Ocean.Pclosure_MPa[clath], phiMin_frac=Planet.Ocean.phiMin_frac, - EXTRAP=Params.EXTRAP_ICE[phaseStr], + EXTRAP=Params.EXTRAP_ICE[phaseStr], kThermConst_WmK=Planet.Ocean.kThermIce_WmK, mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}) - clathStable = iceEOS[phaseIndex].fn_phase(P_MPa, T_K, grid=True) + clathStable = iceEOS[phaseStr].fn_phase(P_MPa, T_K, grid=True) phases[clathStable == phaseIndex] = phaseIndex oceanListEOS.append(oceanEOS) phasesList.append(phases) @@ -351,150 +400,9 @@ def PlotHydroPhase(PlanetList, Params): ax.set_ylim([Pmin_MPa, Pmax_MPa]) ax.invert_yaxis() plt.tight_layout() - fig.savefig(Params.FigureFiles.vphase, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(Params.FigureFiles.vphase, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Hydrosphere phase diagram saved to file: {Params.FigureFiles.vphase}') plt.close() -def OldPlotHydroPhase(PlanetList, Params): - - if FigMisc.PminHydro_MPa is None: - Pmin_MPa = np.min([Planet.P_MPa[0] for Planet in PlanetList]) - else: - Pmin_MPa = FigMisc.PminHydro_MPa - if FigMisc.PmaxHydro_MPa is None: - Pmax_MPa = np.max([Planet.P_MPa[Planet.Steps.nHydro-1] for Planet in PlanetList]) - else: - Pmax_MPa = FigMisc.PmaxHydro_MPa - if FigMisc.TminHydro_K is None: - if not np.any([Planet.Do.NO_OCEAN for Planet in PlanetList]): - Tmin_K = np.min([np.min(Planet.T_K[:Planet.Steps.nHydro]) for Planet in PlanetList]) - else: - Tmin_K = np.min([np.min(Planet.T_K) for Planet in PlanetList]) - else: - Tmin_K = FigMisc.TminHydro_K - if FigMisc.TmaxHydro_K is None: - if np.all([Planet.Do.NO_OCEAN for Planet in PlanetList]): - Tmax_K = np.max([Planet.Sil.THydroMax_K for Planet in PlanetList]) - else: - Tmax_K = np.max([np.max(Planet.T_K[:Planet.Steps.nHydro]) - for Planet in PlanetList if not Planet.Do.NO_OCEAN]) - else: - Tmax_K = FigMisc.TmaxHydro_K - - if os.path.dirname(Params.FigureFiles.vpvtHydro) != 'Comparison': - Planet = PlanetList[0] - if FigMisc.nPphase is None: - P_MPa = Planet.P_MPa[:Planet.Steps.nHydro] - P_MPa = P_MPa[np.logical_and(P_MPa >= Pmin_MPa, P_MPa <= Pmax_MPa)] - else: - P_MPa = np.linspace(Pmin_MPa, Pmax_MPa, FigMisc.nPphase) - if FigMisc.nTphase is None: - T_K = Planet.T_K[:Planet.Steps.nHydro] - T_K = T_K[np.logical_and(T_K >= Tmin_K, T_K <= Tmax_K)] - else: - T_K = np.linspace(Tmin_K, Tmax_K, FigMisc.nTphase) - # Load EOS independently from model run, because we will query wider ranges of conditions - oceanEOS = GetOceanEOS(Planet.Ocean.comp, Planet.Ocean.wOcean_ppt, P_MPa, T_K, - Planet.Ocean.MgSO4elecType, rhoType=Planet.Ocean.MgSO4rhoType, - scalingType=Planet.Ocean.MgSO4scalingType, FORCE_NEW=Params.FORCE_EOS_RECALC, - phaseType=Planet.Ocean.phaseType, EXTRAP=Params.EXTRAP_OCEAN, - sigmaFixed_Sm=Planet.Ocean.sigmaFixed_Sm, LOOKUP_HIRES=Planet.Do.OCEAN_PHASE_HIRES) - - phases = oceanEOS.fn_phase(P_MPa, T_K, grid=True).astype(int) - # Add clathrates to phase and property diagrams where it is stable (if modeled) - if Planet.Do.CLATHRATE: - clath = PhaseConv(Constants.phaseClath) - if Planet.Do.MIXED_CLATHRATE_ICE: - phaseIndex = Constants.phaseClath + 1 - phaseStr = PhaseConv(phaseIndex) - else: - phaseIndex = Constants.phaseClath - phaseStr = PhaseConv(phaseIndex) - clathEOS = GetIceEOS(P_MPa, T_K, phaseStr, - porosType=Planet.Ocean.porosType[clath], - phiTop_frac=Planet.Ocean.phiMax_frac[clath], - Pclosure_MPa=Planet.Ocean.Pclosure_MPa[clath], - phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE[phaseStr], - mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}) - clathStable = clathEOS.fn_phase(P_MPa, T_K, grid=True).astype(int) - phases[np.where(np.logical_and(clathStable == phaseIndex, - phases == 1))] = phaseIndex - - fig = plt.figure(figsize=FigSize.vphase) - grid = GridSpec(1, 1) - ax = fig.add_subplot(grid[0, 0]) - if Style.GRIDS: - ax.grid() - ax.set_axisbelow(False) - # Labels and titles - ax.set_xlabel(FigLbl.Tlabel) - ax.set_ylabel(FigLbl.PlabelHydro) - - # Set overall figure title - if Params.TITLES: - if "CustomSolution" in Planet.Ocean.comp: - fig.suptitle(f"{Planet.Ocean.comp.split('=')[0].strip()}{FigLbl.hydroPhaseTitle}") - else: - fig.suptitle(f"{Planet.compStr}{FigLbl.hydroPhaseTitle}") - - # Plot as colormesh - phaseColors = DiscreteCmap.from_list('icePhases', - [Color.oceanCmap(1), - Color.iceIcond, - Color.iceII, - Color.iceIII, - Color.iceV, - Color.iceVI, - Color.clathCond], N=7) - phaseBounds = np.array([0, 0.5, 1.5, 2.5, 4, 5.5, (Constants.phaseClath+6)/2, Constants.phaseClath]) - bNorm = BoundaryNorm(boundaries=phaseBounds, ncolors=7) - ax.pcolormesh(T_K, P_MPa * FigLbl.PmultHydro, phases, norm=bNorm, cmap=phaseColors, rasterized=FigMisc.PT_RASTER) - - ices = np.unique(phases[phases != 0]) - P2D_MPa, T2D_K = np.meshgrid(P_MPa, T_K, indexing='ij') - for ice in ices: - theseP = P2D_MPa[np.where(phases == ice)] - theseT = T2D_K[np.where(phases == ice)] - P = (np.max(theseP) + np.min(theseP)) / 2 - T = (np.max(theseT) + np.min(theseT)) / 2 - ax.text(T, P, PhaseConv(ice), ha='center', va='center', fontsize=FigLbl.hydroPhaseSize) - - if np.any(phases == 0): - Pliq = P2D_MPa[np.where(phases == 0)] - Tliq = T2D_K[np.where(phases == 0)] - P = (np.max(Pliq) + np.min(Pliq)) / 2 - T = (np.max(Tliq) + np.min(Tliq)) / 2 - ax.text(T, P, 'liquid', ha='center', va='center', fontsize=FigLbl.hydroPhaseSize) - - # Plot geotherm(s) on top of colormaps - for eachPlanet in PlanetList: - # Geotherm curve - if np.size(PlanetList) > 1: - thisColor = None - else: - thisColor = Color.geothermHydro - if eachPlanet.Do.NO_DIFFERENTIATION or eachPlanet.Do.PARTIAL_DIFFERENTIATION: - Pgeo = eachPlanet.P_MPa * FigLbl.PmultHydro - Tgeo = eachPlanet.T_K - else: - Pgeo = eachPlanet.P_MPa[:eachPlanet.Steps.nHydro] * FigLbl.PmultHydro - Tgeo = eachPlanet.T_K[:eachPlanet.Steps.nHydro] - ax.plot(Tgeo, Pgeo, linewidth=Style.LW_geotherm, linestyle=Style.LS_geotherm, - color=thisColor, label=eachPlanet.label) - - if Params.LEGEND and np.size(PlanetList) > 1: - handles, lbls = ax.get_legend_handles_labels() - ax.legend(handles, lbls) - - ax.set_xlim([Tmin_K, Tmax_K]) - ax.set_ylim([Pmin_MPa, Pmax_MPa]) - ax.invert_yaxis() - plt.tight_layout() - fig.savefig(Params.FigureFiles.vphase, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) - log.debug(f'Hydrosphere phase diagram saved to file: {Params.FigureFiles.vphase}') - plt.close() - - return - class HydrosphereProp: def __init__(self, prop: str, @@ -550,9 +458,9 @@ def PlotIsoThermalPvThydro(PlanetList, Params): Tmin_K = np.max([np.min(Planet.T_K) for Planet in PlanetList]) # Get lowest Tmax if not np.any([Planet.Do.NO_OCEAN for Planet in PlanetList]): - Tmax_K = np.min([Planet.Sil.THydroMax_K for Planet in PlanetList]) + Tmax_K = np.min([np.max(Planet.T_K[:Planet.Steps.nHydro]) for Planet in PlanetList]) else: - Tmax_K = np.min([np.max(Planet.T_K[:Planet.Steps.nHydro]) for Planet in PlanetList if not Planet.Do.NO_OCEAN]) + Tmax_K = np.min([Planet.Sil.THydroMax_K for Planet in PlanetList]) if FigMisc.nPhydro is None: Planet = PlanetList[0] @@ -584,17 +492,21 @@ def PlotIsoThermalPvThydro(PlanetList, Params): rhoType=Planet.Ocean.MgSO4rhoType, scalingType=Planet.Ocean.MgSO4scalingType, FORCE_NEW=Params.FORCE_EOS_RECALC, phaseType=Planet.Ocean.phaseType, EXTRAP=Params.EXTRAP_OCEAN, sigmaFixed_Sm=Planet.Ocean.sigmaFixed_Sm, - LOOKUP_HIRES=Planet.Do.OCEAN_PHASE_HIRES) + LOOKUP_HIRES=Planet.Do.OCEAN_PHASE_HIRES, kThermConst_WmK=Planet.Ocean.kThermWater_WmK, + propsStepReductionFactor=Planet.Ocean.propsStepReductionFactor) + phases = oceanEOS.fn_phase(P_MPa, T_K, grid=True).astype(int) new_ices = set([PhaseConv(ice) for ice in np.unique(np.append(phases[phases != 0], 1))]) if not new_ices.issubset(ices): ices = new_ices - iceEOS = {PhaseInv(ice): GetIceEOS(P_MPa, T_K, ice, porosType=Planet.Ocean.porosType[ice], + iceEOS = {ice: GetIceEOS(P_MPa, T_K, ice, porosType=Planet.Ocean.porosType[ice], phiTop_frac=Planet.Ocean.phiMax_frac[ice], Pclosure_MPa=Planet.Ocean.Pclosure_MPa[ice], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE[ice], - ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, - mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}) for ice in ices} + kThermConst_WmK=Planet.Ocean.kThermIce_WmK, + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, + mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}) + for ice in ices} # Add clathrates to phase and property diagrams where it is stable (if modeled) if Planet.Do.CLATHRATE: clath = PhaseConv(Constants.phaseClath) @@ -609,8 +521,10 @@ def PlotIsoThermalPvThydro(PlanetList, Params): iceEOS[phaseStr] = GetIceEOS(P_MPa, T_K, phaseStr, porosType=Planet.Ocean.porosType[clath], phiTop_frac=Planet.Ocean.phiMax_frac[clath], Pclosure_MPa=Planet.Ocean.Pclosure_MPa[clath], - phiMin_frac=Planet.Ocean.phiMin_frac, + phiMin_frac=Planet.Ocean.phiMin_frac, + kThermConst_WmK=Planet.Ocean.kThermIce_WmK, EXTRAP=Params.EXTRAP_ICE[phaseStr], + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}) clathStable = iceEOS[phaseStr].fn_phase(P_MPa, T_K, grid=True) phases[clathStable == phaseIndex] = phaseIndex @@ -640,7 +554,7 @@ def PlotIsoThermalPvThydro(PlanetList, Params): ice = PhaseInv(iceStr) if np.any(phases == ice): if prop.prop != 'sig': - ice_prop_data = getattr(iceEOS[ice], prop.fn_name)(P_MPa, T_K, grid=True) + ice_prop_data = getattr(iceEOS[iceStr], prop.fn_name)(P_MPa, T_K, grid=True) if prop.prop in ['VP', 'KS', 'VS', 'GS']: prop_data[np.where(phases == ice)] = ice_prop_data[prop.ice_prop_index][ np.where(phases == ice)] @@ -745,7 +659,7 @@ def PlotIsoThermalPvThydro(PlanetList, Params): else: saveFile = Params.FigureFiles.comparisonFileGenerator(FirstPlanet.saveLabel, SecondPlanet.saveLabel, 'isoThermvpvtHydro') - fig.savefig(saveFile, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(saveFile, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'IsoTherm Hydrosphere PT properties plot saved to file: {saveFile}') plt.close() @@ -830,17 +744,20 @@ def PlotPvThydro(PlanetList, Params): rhoType=Planet.Ocean.MgSO4rhoType, scalingType=Planet.Ocean.MgSO4scalingType, FORCE_NEW=Params.FORCE_EOS_RECALC, phaseType=Planet.Ocean.phaseType, EXTRAP=Params.EXTRAP_OCEAN, sigmaFixed_Sm=Planet.Ocean.sigmaFixed_Sm, - LOOKUP_HIRES=Planet.Do.OCEAN_PHASE_HIRES) + LOOKUP_HIRES=Planet.Do.OCEAN_PHASE_HIRES, kThermConst_WmK=Planet.Ocean.kThermWater_WmK, + propsStepReductionFactor=Planet.Ocean.propsStepReductionFactor) + phases = oceanEOS.fn_phase(P_MPa, T_K, grid=True).astype(int) new_ices = set([PhaseConv(ice) for ice in np.unique(np.append(phases[phases != 0], 1))]) if not new_ices.issubset(ices): ices = new_ices - iceEOS = {PhaseInv(ice): GetIceEOS(P_MPa, T_K, ice, porosType=Planet.Ocean.porosType[ice], + iceEOS = {ice: GetIceEOS(P_MPa, T_K, ice, porosType=Planet.Ocean.porosType[ice], phiTop_frac=Planet.Ocean.phiMax_frac[ice], Pclosure_MPa=Planet.Ocean.Pclosure_MPa[ice], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE[ice], - ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, - mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}) for ice in ices} + kThermConst_WmK=Planet.Ocean.kThermIce_WmK, + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}) + for ice in ices} # Add clathrates to phase and property diagrams where it is stable (if modeled) if Planet.Do.CLATHRATE: clath = PhaseConv(Constants.phaseClath) @@ -882,7 +799,7 @@ def PlotPvThydro(PlanetList, Params): ice = PhaseInv(iceStr) if np.any(phases == ice): if prop.prop != 'sig': - ice_prop_data = getattr(iceEOS[ice], prop.fn_name)(P_MPa, T_K, grid=True) + ice_prop_data = getattr(iceEOS[iceStr], prop.fn_name)(P_MPa, T_K, grid=True) if prop.prop in ['VP', 'KS', 'VS', 'GS']: prop_data[np.where(phases == ice)] = ice_prop_data[prop.ice_prop_index][ np.where(phases == ice)] @@ -1023,11 +940,11 @@ def PlotPvThydro(PlanetList, Params): # Add horizontal lines (optional) cbar.ax.hlines(p5_pos, 0, 1, transform=cbar.ax.transAxes, colors='red', linestyles='--', linewidth=1) cbar.ax.hlines(p95_pos, 0, 1, transform=cbar.ax.transAxes, colors='red', linestyles='--', linewidth=1) - # Display the actual data min and max in the colorbar label - cbar.ax.text(1.05, 1.05, f'Max: {data_max:.2e}', transform=cbar.ax.transAxes, - fontsize=8, verticalalignment='bottom') - cbar.ax.text(1.05, -0.05, f'Min: {data_min:.2e}', transform=cbar.ax.transAxes, - fontsize=8, verticalalignment='top') + # Display the actual data min and max in the colorbar label + cbar.ax.text(1.05, 1.05, f'Max: {data_max:.2e}', transform=cbar.ax.transAxes, + fontsize=8, verticalalignment='bottom') + cbar.ax.text(1.05, -0.05, f'Min: {data_min:.2e}', transform=cbar.ax.transAxes, + fontsize=8, verticalalignment='top') # Set labels, title, etc. ax.set_xlabel(FigLbl.Tlabel) ax.set_ylabel(FigLbl.PlabelHydro) @@ -1063,7 +980,7 @@ def PlotPvThydro(PlanetList, Params): saveFile = Params.FigureFiles.vpvtHydro else: saveFile = Params.FigureFiles.comparisonFileGenerator(FirstPlanet.saveLabel, SecondPlanet.saveLabel, 'vpvtHydro') - fig.savefig(saveFile, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(saveFile, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Hydrosphere PT properties plot saved to file: {saveFile}') plt.close() @@ -1085,188 +1002,6 @@ def PlotCustomSolutionProperties(PlanetList, Params): allspeciesax.set_ylabel("Depth z (km)") aqueouspseciesax.set_xlabel("Aqueous species (moles per kg fluid)") -def PlotPvThydroOld(PlanetList, Params): - - if os.path.dirname(Params.FigureFiles.vpvtHydro) != 'Comparison': - Planet = PlanetList[0] - if FigMisc.PminHydro_MPa is None: - Pmin_MPa = np.min([Planet.P_MPa[0] for Planet in PlanetList]) - else: - Pmin_MPa = FigMisc.PminHydro_MPa - if FigMisc.PmaxHydro_MPa is None: - Pmax_MPa = np.max([Planet.P_MPa[Planet.Steps.nHydro-1] for Planet in PlanetList]) - else: - Pmax_MPa = FigMisc.PmaxHydro_MPa - if FigMisc.TminHydro_K is None: - if not np.any([Planet.Do.NO_OCEAN for Planet in PlanetList]): - Tmin_K = np.min([np.min(Planet.T_K[:Planet.Steps.nHydro]) for Planet in PlanetList]) - else: - Tmin_K = np.min([np.min(Planet.T_K) for Planet in PlanetList]) - else: - Tmin_K = FigMisc.TminHydro_K - if FigMisc.TmaxHydro_K is None: - if np.all([Planet.Do.NO_OCEAN for Planet in PlanetList]): - Tmax_K = np.max([Planet.Sil.THydroMax_K for Planet in PlanetList]) - else: - Tmax_K = np.max([np.max(Planet.T_K[:Planet.Steps.nHydro]) - for Planet in PlanetList if not Planet.Do.NO_OCEAN]) - else: - Tmax_K = FigMisc.TmaxHydro_K - - if FigMisc.nPhydro is None: - P_MPa = Planet.P_MPa[:Planet.Steps.nHydro] - P_MPa = P_MPa[np.logical_and(P_MPa >= Pmin_MPa, P_MPa <= Pmax_MPa)] - else: - P_MPa = np.linspace(Pmin_MPa, Pmax_MPa, FigMisc.nPhydro) - if FigMisc.nThydro is None: - T_K = Planet.T_K[:Planet.Steps.nHydro] - T_K = T_K[np.logical_and(T_K >= Tmin_K, T_K <= Tmax_K)] - else: - T_K = np.linspace(Tmin_K, Tmax_K, FigMisc.nThydro) - - # Load EOS independently from model run, because we will query wider ranges of conditions - oceanEOS = GetOceanEOS(Planet.Ocean.comp, Planet.Ocean.wOcean_ppt, P_MPa, T_K, - Planet.Ocean.MgSO4elecType, rhoType=Planet.Ocean.MgSO4rhoType, - scalingType=Planet.Ocean.MgSO4scalingType, FORCE_NEW=Params.FORCE_EOS_RECALC, - phaseType=Planet.Ocean.phaseType, EXTRAP=Params.EXTRAP_OCEAN, - sigmaFixed_Sm=Planet.Ocean.sigmaFixed_Sm, LOOKUP_HIRES=Planet.Do.OCEAN_PHASE_HIRES) - - phases = oceanEOS.fn_phase(P_MPa, T_K, grid=True).astype(int) - ices = [PhaseConv(ice) for ice in np.unique(np.append(phases[phases != 0], 1))] - iceEOS = {PhaseInv(ice): GetIceEOS(P_MPa, T_K, ice, - porosType=Planet.Ocean.porosType[ice], - phiTop_frac=Planet.Ocean.phiMax_frac[ice], - Pclosure_MPa=Planet.Ocean.Pclosure_MPa[ice], - phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE[ice], - ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, - mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}) - for ice in ices} - # Add clathrates to phase and property diagrams where it is stable (if modeled) - if Planet.Do.CLATHRATE: - clath = PhaseConv(Constants.phaseClath) - if Planet.Do.MIXED_CLATHRATE_ICE: - phaseIndex = Constants.phaseClath + 1 - phaseStr = PhaseConv(phaseIndex) - else: - phaseIndex = Constants.phaseClath - phaseStr = PhaseConv(phaseIndex) - ices.append(phaseStr) - iceEOS[phaseStr] = GetIceEOS(P_MPa, T_K, phaseStr, - porosType=Planet.Ocean.porosType[clath], - phiTop_frac=Planet.Ocean.phiMax_frac[clath], - Pclosure_MPa=Planet.Ocean.Pclosure_MPa[clath], - phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE[phaseStr], - mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}) - clathStable = iceEOS[Constants.phaseClath].fn_phase(P_MPa, T_K, grid=True) - phases[clathStable == Constants.phaseClath] = Constants.phaseClath - - fig = plt.figure(figsize=FigSize.vpvt) - grid = GridSpec(2, 4) - axes = np.array([[fig.add_subplot(grid[i, j]) for j in range(4)] for i in range(2)]) - axf = axes.flatten() - if Style.GRIDS: - [ax.grid() for ax in axf] - [ax.set_axisbelow(False) for ax in axf] - # Labels and titles - [ax.set_xlabel(FigLbl.Tlabel) for ax in axes[1, :]] - [ax.set_ylabel(FigLbl.PlabelHydro) for ax in axes[:, 0]] - [ax.set_xlim([Tmin_K, Tmax_K]) for ax in axf] - [ax.set_ylim([Pmin_MPa, Pmax_MPa]) for ax in axf] - [ax.invert_yaxis() for ax in axf] - axes[0,0].set_title(FigLbl.rhoLabel) - axes[1,0].set_title(FigLbl.CpLabel) - axes[1,1].set_title(FigLbl.alphaLabel) - axes[0,1].set_title(FigLbl.sigLabel) - axes[0,2].set_title(FigLbl.VPlabel) - axes[1,2].set_title(FigLbl.VSlabel) - axes[0,3].set_title(FigLbl.KSlabel) - axes[1,3].set_title(FigLbl.GSlabel) - - # Set overall figure title - if Params.TITLES: - fig.suptitle(f'{Planet.name}{FigLbl.PvTtitleHydro}') - - # Get data to plot -- ocean EOS properties first - rho = oceanEOS.fn_rho_kgm3(P_MPa, T_K, grid=True) - Cp = oceanEOS.fn_Cp_JkgK(P_MPa, T_K, grid=True) - alpha = oceanEOS.fn_alpha_pK(P_MPa, T_K, grid=True) - VP, KS = oceanEOS.fn_Seismic(P_MPa, T_K, grid=True) - sig = oceanEOS.fn_sigma_Sm(P_MPa, T_K, grid=True) - VS, GS = (np.empty_like(rho)*np.nan for _ in range(2)) - - # Exclude obviously erroneous Cp values, which happen when extending beyond the knots - # for the input EOS. This is mainly a problem with GSW and Seawater compositions. - Cp[np.logical_or(Cp < 3200, Cp > 5200)] = np.nan - - # Now get data for all ice EOSs and replace in grid - for iceStr in ices: - ice = PhaseInv(iceStr) - rho[np.where(phases == ice)] = iceEOS[ice].fn_rho_kgm3(P_MPa, T_K, grid=True)[np.where(phases == ice)] - Cp[np.where(phases == ice)] = iceEOS[ice].fn_Cp_JkgK(P_MPa, T_K, grid=True)[np.where(phases == ice)] - alpha[np.where(phases == ice)] = iceEOS[ice].fn_alpha_pK(P_MPa, T_K, grid=True)[np.where(phases == ice)] - VPice, VSice, KSice, GSice = iceEOS[ice].fn_Seismic(P_MPa, T_K, grid=True) - VP[np.where(phases == ice)] = VPice[np.where(phases == ice)] - VS[np.where(phases == ice)] = VSice[np.where(phases == ice)] - KS[np.where(phases == ice)] = KSice[np.where(phases == ice)] - GS[np.where(phases == ice)] = GSice[np.where(phases == ice)] - sig[np.where(phases == ice)] = Planet.Ocean.sigmaIce_Sm[iceStr] - - # Highlight places where alpha is negative with opposite side of diverging colormap, 0 pegged to middle - minAlpha = np.minimum(0, np.min(alpha)) - alphaCmap = Color.ComboPvThydroCmap(minAlpha, np.max(alpha)) - - # Plot colormaps of hydrosphere data - Pscaled = P_MPa * FigLbl.PmultHydro - rhoPlot = axes[0,0].pcolormesh(T_K, Pscaled, rho, cmap=Color.PvThydroCmap, rasterized=FigMisc.PT_RASTER) - CpPlot = axes[1,0].pcolormesh(T_K, Pscaled, Cp, cmap=Color.PvThydroCmap, rasterized=FigMisc.PT_RASTER) - alphaPlot = axes[1,1].pcolormesh(T_K, Pscaled, alpha, cmap=alphaCmap, rasterized=FigMisc.PT_RASTER) - sigPlot = axes[0,1].pcolormesh(T_K, Pscaled, sig, cmap=Color.PvThydroCmap, rasterized=FigMisc.PT_RASTER) - VPplot = axes[0,2].pcolormesh(T_K, Pscaled, VP, cmap=Color.PvThydroCmap, rasterized=FigMisc.PT_RASTER) - VSplot = axes[1,2].pcolormesh(T_K, Pscaled, VS, cmap=Color.PvThydroCmap, rasterized=FigMisc.PT_RASTER) - KSplot = axes[0,3].pcolormesh(T_K, Pscaled, KS, cmap=Color.PvThydroCmap, rasterized=FigMisc.PT_RASTER) - GSplot = axes[1,3].pcolormesh(T_K, Pscaled, GS, cmap=Color.PvThydroCmap, rasterized=FigMisc.PT_RASTER) - - # Add colorbars for each plot - cbars = [ - fig.colorbar(rhoPlot, ax=axes[0,0]), - fig.colorbar(CpPlot, ax=axes[1,0]), - fig.colorbar(alphaPlot, ax=axes[1,1]), - fig.colorbar(sigPlot, ax=axes[0,1]), - fig.colorbar(VPplot, ax=axes[0,2]), - fig.colorbar(VSplot, ax=axes[1,2]), - fig.colorbar(KSplot, ax=axes[0,3]), - fig.colorbar(GSplot, ax=axes[1,3]) - ] - - # Plot geotherm on top of colormaps - for eachPlanet in PlanetList: - # Geotherm curve - if np.size(PlanetList) > 1: - thisColor = None - else: - thisColor = Color.geothermHydro - if Planet.Do.NO_DIFFERENTIATION or Planet.Do.PARTIAL_DIFFERENTIATION: - Pgeo = eachPlanet.P_MPa * FigLbl.PmultHydro - Tgeo = eachPlanet.T_K - else: - Pgeo = eachPlanet.P_MPa[:eachPlanet.Steps.nHydro] * FigLbl.PmultHydro - Tgeo = eachPlanet.T_K[:eachPlanet.Steps.nHydro] - [ax.plot(Tgeo, Pgeo, linewidth=Style.LW_geotherm, linestyle=Style.LS_geotherm, - color=thisColor, label=eachPlanet.label) for ax in axf] - - if Params.LEGEND and np.size(PlanetList) > 1: - handles, lbls = axes[-1,0].get_legend_handles_labels() - axes[0,-1].legend(handles, lbls) - - - plt.tight_layout() - fig.savefig(Params.FigureFiles.vpvtHydro, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) - log.debug(f'Hydrosphere PT properties plot saved to file: {Params.FigureFiles.vpvtHydro}') - plt.close() - - return - - def PlotPvTPerpleX(PlanetList, Params): if os.path.dirname(Params.FigureFiles.vpvtPerpleX) != 'Comparison': @@ -1276,12 +1011,12 @@ def PlotPvTPerpleX(PlanetList, Params): kThermConst_WmK=Planet.Sil.kTherm_WmK, HtidalConst_Wm3=Planet.Sil.Htidal_Wm3, porosType=Planet.Sil.porosType, phiTop_frac=Planet.Sil.phiRockMax_frac, Pclosure_MPa=Planet.Sil.Pclosure_MPa, phiMin_frac=Planet.Sil.phiMin_frac, - EXTRAP=Params.EXTRAP_SIL) + EXTRAP=Params.EXTRAP_SIL, etaSilFixed_Pas=Planet.Sil.etaRock_Pas, etaCoreFixed_Pas=[Planet.Core.etaFeSolid_Pas, Planet.Core.etaFeLiquid_Pas]) INCLUDING_CORE = FigMisc.PVT_INCLUDE_CORE and Planet.Do.Fe_CORE if INCLUDING_CORE and Planet.Core.EOS is None: Planet.Core.EOS = GetInnerEOS(Planet.Core.coreEOS, EOSinterpMethod=Params.lookupInterpMethod, Fe_EOS=True, kThermConst_WmK=Planet.Core.kTherm_WmK, EXTRAP=Params.EXTRAP_Fe, - wFeCore_ppt=Planet.Core.wFe_ppt, wScore_ppt=Planet.Core.wS_ppt) + wFeCore_ppt=Planet.Core.wFe_ppt, wScore_ppt=Planet.Core.wS_ppt, etaSilFixed_Pas=Planet.Sil.etaRock_Pas, etaCoreFixed_Pas=[Planet.Core.etaFeSolid_Pas, Planet.Core.etaFeLiquid_Pas]) # Check that it's worth converting to GPa if that setting has been selected -- reset labels if not if Planet.P_MPa[-1] < 100 and FigLbl.PFULL_IN_GPa: @@ -1412,8 +1147,239 @@ def PlotPvTPerpleX(PlanetList, Params): color=Color.geothermInner) for ax in axf] plt.tight_layout() - fig.savefig(Params.FigureFiles.vpvtPerpleX, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(Params.FigureFiles.vpvtPerpleX, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Silicate/core PT properties plot saved to file: {Params.FigureFiles.vpvtPerpleX}') plt.close() return + +def PlotMeltingCurves(PlanetList, Params): + """ + Plot melting curves for multiple PlanetProfile models on the same figure. + + Args: + PlanetList: List of Planet objects to plot melting curves for + Params: Parameters object containing figure settings + """ + if os.path.dirname(Params.FigureFiles.vmeltingCurves) != 'Comparison': + # Determine pressure and temperature ranges similar to PlotHydroPhase + if FigMisc.PminHydro_MPa is None: + Pmin_MPa = np.min([Planet.P_MPa[0] for Planet in PlanetList]) + else: + Pmin_MPa = FigMisc.PminHydro_MPa + if FigMisc.PmaxHydro_MPa is None: + Pmax_MPa = np.max([Planet.P_MPa[Planet.Steps.nHydro-1] for Planet in PlanetList]) + else: + Pmax_MPa = FigMisc.PmaxHydro_MPa + if FigMisc.TminMeltingCurve_K is None: + if not np.any([Planet.Do.NO_OCEAN for Planet in PlanetList]): + Tmin_K = np.min([np.min(Planet.T_K[:Planet.Steps.nHydro]) for Planet in PlanetList]) + else: + Tmin_K = np.min([np.min(Planet.T_K) for Planet in PlanetList]) + else: + Tmin_K = FigMisc.TminMeltingCurve_K + if FigMisc.TmaxMeltingCurve_K is None: + if np.all([Planet.Do.NO_OCEAN for Planet in PlanetList]): + Tmax_K = np.max([Planet.Sil.THydroMax_K for Planet in PlanetList]) + else: + Tmax_K = np.max([np.max(Planet.T_K[:Planet.Steps.nHydro]) + for Planet in PlanetList if not Planet.Do.NO_OCEAN]) + if Pmax_MPa <= Constants.PminHPices_MPa: + Tmax_K = np.min([Tmax_K, 280]) # Set the cut off at 280K since melting curve won't be above this point for HP ices + else: + Tmax_K = FigMisc.TmaxMeltingCurve_K + + # Create pressure and temperature grids for melting curve calculation + P_MPa = np.linspace(Pmin_MPa, Pmax_MPa, FigMisc.nPmeltingCurve) + T_K = np.linspace(Tmin_K, Tmax_K, FigMisc.nTmeltingCurve) + + # Get unique ocean compositions and salinities + comps = [] + for Planet in PlanetList: + if "CustomSolution" in Planet.Ocean.comp: + if Planet.Ocean.comp.split('=')[0] not in comps: + comps.append(Planet.Ocean.comp) + else: + comps.append(Planet.Ocean.comp) + comps = np.unique(comps) + + # Create figure + fig = plt.figure(figsize=FigSize.vmeltingCurves) + ax = fig.add_subplot(1, 1, 1) + + if Style.GRIDS: + ax.grid() + ax.set_axisbelow(True) + + # Labels and titles + ax.set_xlabel(FigLbl.Tlabel) + ax.set_ylabel(FigLbl.PlabelHydro) + + # Set overall figure title + if Params.TITLES: + if Params.ALL_ONE_BODY: + fig.suptitle(f'{PlanetList[0].name}{FigLbl.meltingCurvesTitle}', fontsize=FigLbl.hydroTitleSize) + else: + fig.suptitle(f'{FigLbl.meltingCurvesTitle}', fontsize=FigLbl.hydroTitleSize) + + # Get min and max salinities and temps for each comp for scaling (similar to PlotHydrosphereProps) + wMinMax_ppt = {} + TminMax_K = {} + for comp in comps: + if comp != 'none': + wAll_ppt = [Planet.Ocean.wOcean_ppt for Planet in PlanetList if Planet.Ocean.comp == comp] + wMinMax_ppt[comp] = [np.min(wAll_ppt), np.max(wAll_ppt)] + Tall_K = [Planet.Bulk.Tb_K for Planet in PlanetList if Planet.Ocean.comp == comp] + TminMax_K[comp] = [np.min(Tall_K), np.max(Tall_K)] + # Reset to default if all models are the same or if desired + if not FigMisc.RELATIVE_Tb_K or TminMax_K[comp][0] == TminMax_K[comp][1]: + TminMax_K[comp] = Color.Tbounds_K + + # Calculate and plot melting curves for each unique composition + melting_curves = {} # Store melting curves by composition + for comp in comps: + if comp == 'none': + continue + + # Get the first planet with this composition to use for EOS + comp_planets = [Planet for Planet in PlanetList if Planet.Ocean.comp == comp] + if not comp_planets: + continue + + ref_planet = comp_planets[0] + + # Load EOS for this composition + oceanEOS = GetOceanEOS(ref_planet.Ocean.comp, ref_planet.Ocean.wOcean_ppt, P_MPa, T_K, + ref_planet.Ocean.MgSO4elecType, rhoType=ref_planet.Ocean.MgSO4rhoType, + scalingType=ref_planet.Ocean.MgSO4scalingType, FORCE_NEW=Params.FORCE_EOS_RECALC, + phaseType=ref_planet.Ocean.phaseType, EXTRAP=Params.EXTRAP_OCEAN, + sigmaFixed_Sm=ref_planet.Ocean.sigmaFixed_Sm, LOOKUP_HIRES=ref_planet.Do.OCEAN_PHASE_HIRES, kThermConst_WmK=ref_planet.Ocean.kThermWater_WmK, + propsStepReductionFactor=ref_planet.Ocean.propsStepReductionFactor) + + # Calculate phase diagram + phases = oceanEOS.fn_phase(P_MPa, T_K, grid=True).astype(int) + + # Find melting curve (transition from ice to liquid) + melting_points = [] + for i, P in enumerate(P_MPa): + # Find where phase changes from ice (non-zero) to liquid (zero) + phase_col = phases[i, :] + transitions = np.where(np.diff(phase_col) == -1)[0] # Ice to liquid transitions + + if len(transitions) > 0: + # Use the first transition (lowest temperature) + T_melt = T_K[transitions[0]] + melting_points.append((T_melt, P)) + + if melting_points: + melting_curves[comp] = np.array(melting_points) + else: + log.warning(f'No melting curve found for composition {comp}') + + # Plot melting curves with appropriate styling + for comp in comps: + if comp == 'none' or comp not in melting_curves: + continue + + melting_data = melting_curves[comp] + T_melt = melting_data[:, 0] + P_melt = melting_data[:, 1] + + # Set style options (similar to PlotHydrosphereProps) + if FigMisc.MANUAL_HYDRO_COLORS: + Color.Tbounds_K = TminMax_K[comp] + thisColor = Color.cmap[comp](Color.GetNormT(np.mean(T_melt))) + else: + thisColor = None + + if FigMisc.SCALE_HYDRO_LW and wMinMax_ppt[comp][0] != wMinMax_ppt[comp][1]: + thisLW = Style.GetLW(np.mean([Planet.Ocean.wOcean_ppt for Planet in PlanetList if Planet.Ocean.comp == comp]), wMinMax_ppt[comp]) + else: + thisLW = FigMisc.MELTING_CURVE_LINE_WIDTH + if FigMisc.LS_SOLID_MELTING_CURVES: + thisLS = 'solid' + else: + thisLS = Style.LS[comp] + # Create label for this composition + if "CustomSolution" in comp: + comp_label = comp.split('=')[0].strip() + else: + comp_label = comp + + # Plot melting curve + ax.plot(T_melt, P_melt * FigLbl.PmultHydro, + label=comp_label, color=thisColor, linewidth=thisLW, + linestyle=thisLS) + + # Mark model melting points if requested + if FigMisc.MARK_MODEL_POINTS: + for Planet in PlanetList: + if Planet.Ocean.comp == 'none': + continue + + # Get model melting point (Tb_K, Pb_MPa) + T_model = Planet.Bulk.Tb_K + P_model = Planet.Pb_MPa + + # Set style for this planet + if FigMisc.MANUAL_HYDRO_COLORS: + Color.Tbounds_K = TminMax_K[Planet.Ocean.comp] + thisColor = Color.cmap[Planet.Ocean.comp](Color.GetNormT(T_model)) + else: + thisColor = None + + if FigMisc.SCALE_HYDRO_LW and wMinMax_ppt[Planet.Ocean.comp][0] != wMinMax_ppt[Planet.Ocean.comp][1]: + thisLW = Style.GetLW(Planet.Ocean.wOcean_ppt, wMinMax_ppt[Planet.Ocean.comp]) + else: + thisLW = FigMisc.MELTING_CURVE_LINE_WIDTH + + # Plot model point + ax.scatter(T_model, P_model * FigLbl.PmultHydro, + color=thisColor, s=FigMisc.MODEL_POINT_SIZE, + marker='o', edgecolors='black', linewidth=0.5) + + # Plot geotherms if requested + if FigMisc.SHOW_GEOTHERM: + for Planet in PlanetList: + if Planet.Ocean.comp == 'none': + continue + + # Set style for this planet + if FigMisc.MANUAL_HYDRO_COLORS: + Color.Tbounds_K = TminMax_K[Planet.Ocean.comp] + thisColor = Color.cmap[Planet.Ocean.comp](Color.GetNormT(Planet.Bulk.Tb_K)) + else: + thisColor = Color.geothermHydro + + if FigMisc.SCALE_HYDRO_LW and wMinMax_ppt[Planet.Ocean.comp][0] != wMinMax_ppt[Planet.Ocean.comp][1]: + thisLW = Style.GetLW(Planet.Ocean.wOcean_ppt, wMinMax_ppt[Planet.Ocean.comp]) + else: + thisLW = Style.LW_geotherm + + # Plot geotherm + if Planet.Do.NO_DIFFERENTIATION or Planet.Do.PARTIAL_DIFFERENTIATION: + Pgeo = Planet.P_MPa * FigLbl.PmultHydro + Tgeo = Planet.T_K + else: + Pgeo = Planet.P_MPa[:Planet.Steps.nHydro] * FigLbl.PmultHydro + Tgeo = Planet.T_K[:Planet.Steps.nHydro] + + ax.plot(Tgeo, Pgeo, linewidth=thisLW, linestyle=Style.LS_geotherm, + color=thisColor, alpha=0.7, label=f'{Planet.label} geotherm') + + # Set axis limits + ax.set_xlim([Tmin_K, Tmax_K]) + ax.set_ylim([Pmin_MPa * FigLbl.PmultHydro, Pmax_MPa * FigLbl.PmultHydro]) + ax.invert_yaxis() + + # Add legend if requested + if Params.LEGEND: + handles, labels = ax.get_legend_handles_labels() + ax.legend(handles, labels, loc='upper left') + + plt.tight_layout() + fig.savefig(Params.FigureFiles.vmeltingCurves, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) + log.debug(f'Melting curves plot saved to file: {Params.FigureFiles.vmeltingCurves}') + plt.close() + + return diff --git a/PlanetProfile/Plotting/ProfilePlots.py b/PlanetProfile/Plotting/ProfilePlots.py index 30d34ed6..f9525a32 100644 --- a/PlanetProfile/Plotting/ProfilePlots.py +++ b/PlanetProfile/Plotting/ProfilePlots.py @@ -6,7 +6,7 @@ from matplotlib.patches import Wedge from scipy.interpolate import interp1d from PlanetProfile.GetConfig import Color, Style, FigLbl, FigSize, FigMisc -from PlanetProfile.Plotting.PTPlots import PlotHydroPhase, PlotPvThydro, PlotPvTPerpleX, PlotHydrosphereSpecies, PlotIsoThermalPvThydro +from PlanetProfile.Plotting.PTPlots import PlotHydroPhase, PlotPvThydro, PlotPvTPerpleX, PlotHydrosphereSpecies, PlotIsoThermalPvThydro, PlotMeltingCurves from PlanetProfile.Thermodynamics.RefProfiles.RefProfiles import CalcRefProfiles, ReloadRefProfiles from PlanetProfile.Utilities.Indexing import GetPhaseIndices, PhaseConv from PlanetProfile.Utilities.defineStructs import Constants @@ -38,7 +38,9 @@ def GeneratePlots(PlanetList, Params): PlotGravPres(PlanetList, Params) if Params.PLOT_HYDROSPHERE and np.any([not Planet.Do.NO_OCEAN for Planet in PlanetList]): PlotHydrosphereProps(PlanetList, Params) - if Params.PLOT_SPECIES_HYDROSPHERE and np.any([not Planet.Do.NO_OCEAN for Planet in PlanetList]): + if Params.PLOT_HYDROSPHERE_THERMODYNAMICS and np.any([not Planet.Do.NO_OCEAN for Planet in PlanetList]): + PlotHydrosphereThermodynamics(PlanetList, Params) + if Params.PLOT_SPECIES_HYDROSPHERE and Params.CALC_OCEAN_PROPS and np.any([not Planet.Do.NO_OCEAN for Planet in PlanetList]): PlotHydrosphereSpecies(PlanetList, Params) # if Params.PLOT_CUSTOMSOLUTION_EOS_PROPERTIES_TABLE and np.any([not Planet.Do.NO_OCEAN for Planet in PlanetList]) and np.any( # ["CustomSolution" in Planet.Ocean.comp for Planet in PlanetList]): @@ -61,8 +63,10 @@ def GeneratePlots(PlanetList, Params): PlotPvThydro(PlanetList, Params) if Params.PLOT_PVT_ISOTHERMAL_HYDRO and np.any([not Planet.Do.NO_H2O for Planet in PlanetList]): PlotIsoThermalPvThydro(PlanetList, Params) - if Params.PLOT_PVT_INNER and not Params.SKIP_INNER: + if Params.PLOT_PVT_INNER and not Params.SKIP_INNER and not np.any([Planet.Do.CONSTANT_INNER_DENSITY for Planet in PlanetList]): PlotPvTPerpleX(PlanetList, Params) + if Params.PLOT_MELTING_CURVES and np.any([not Planet.Do.NO_H2O for Planet in PlanetList]): + PlotMeltingCurves(PlanetList, Params) return @@ -102,7 +106,7 @@ def PlotGravPres(PlanetList, Params): axes[1].legend() plt.tight_layout() - fig.savefig(Params.FigureFiles.vgrav, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(Params.FigureFiles.vgrav, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Gravity and pressure plot saved to file: {Params.FigureFiles.vgrav}') plt.close() @@ -111,6 +115,7 @@ def PlotGravPres(PlanetList, Params): def PlotHydrosphereProps(PlanetList, Params): + vRow = 1 if Params.PLOT_SIGS and Params.CALC_CONDUCT: if FigMisc.lowSigCutoff_Sm is None: @@ -153,7 +158,7 @@ def PlotHydrosphereProps(PlanetList, Params): # Generate canvas and add labels - Always 6×7 grid fig = plt.figure(figsize=FigSize.vhydro) - grid = GridSpec(6, 7) + grid = GridSpec(6, 8) # Fixed layout: # - Density: top 4 rows (0-3), left 4 columns (0-3) @@ -200,10 +205,12 @@ def PlotHydrosphereProps(PlanetList, Params): else: axPrho.set_ylabel(FigLbl.PlabelHydro) axPrho.invert_yaxis() + axPrho.tick_params(axis='both', which='major', labelsize=Style.TS_ticks) axTz.set_xlabel(FigLbl.Tlabel) axTz.set_ylabel(FigLbl.zLabel) axTz.invert_yaxis() + axTz.tick_params(axis='both', which='major', labelsize=Style.TS_ticks) zMax = np.max([Planet.z_m[Planet.Steps.nHydro-1]/1e3 for Planet in PlanetList if not Planet.Do.NO_H2O], initial=0) * 1.05 axTz.set_ylim([zMax, 0]) @@ -214,6 +221,7 @@ def PlotHydrosphereProps(PlanetList, Params): axPz.set_xlabel(FigLbl.PlabelHydro) axPz.invert_yaxis() axPz.set_ylim([zMax, 0]) + axPz.tick_params(axis='both', which='major', labelsize=Style.TS_ticks) axes.append(axPz) # Add property plots to right 3 columns (4-6) @@ -224,7 +232,7 @@ def PlotHydrosphereProps(PlanetList, Params): row_ranges = [(0, 6)] elif num_right_plots == 2: # 2 properties: each gets 3 rows - row_ranges = [(0, 3), (3, 6)] + row_ranges = [(0, 2), (2, 6)] elif num_right_plots == 3: # 3 properties: each gets 2 rows row_ranges = [(0, 2), (2, 4), (4, 6)] @@ -239,6 +247,7 @@ def PlotHydrosphereProps(PlanetList, Params): axv[2].set_xlabel(FigLbl.vSiceLabel) [ax.invert_yaxis() for ax in axv] [ax.set_ylim([zMax, 0]) for ax in axv] + [ax.tick_params(axis='both', which='major', labelsize=Style.TS_ticks) for ax in axv] axes = axes + axv elif plot_type == 'seismic': @@ -249,6 +258,7 @@ def PlotHydrosphereProps(PlanetList, Params): axseismic[2].set_xlabel(FigLbl.GSiceLabel) [ax.invert_yaxis() for ax in axseismic] [ax.set_ylim([zMax, 0]) for ax in axseismic] + [ax.tick_params(axis='both', which='major', labelsize=Style.TS_ticks) for ax in axseismic] axes = axes + axseismic elif plot_type == 'sigs': @@ -261,24 +271,27 @@ def PlotHydrosphereProps(PlanetList, Params): axviscz.invert_yaxis() axviscz.set_xscale('log') axviscz.set_ylim([zMax, 0]) + axviscz.tick_params(axis='both', which='major', labelsize=Style.TS_ticks) axes.append(axviscz) else: # Standard conductivity plot spanning all 3 right columns - axsigz = fig.add_subplot(grid[start_row:end_row, 4:7]) + axsigz = fig.add_subplot(grid[start_row:end_row, 4:]) axsigz.set_xlabel(FigLbl.sigLabel) axsigz.invert_yaxis() if FigMisc.LOG_SIG: axsigz.set_xscale('log') axsigz.set_ylim([zMax, 0]) + axsigz.tick_params(axis='both', which='major', labelsize=Style.TS_ticks) axes.append(axsigz) elif plot_type == 'viscosity': # Standalone viscosity plot - axviscz = fig.add_subplot(grid[start_row:end_row, 4:7]) + axviscz = fig.add_subplot(grid[start_row:end_row, 4:]) axviscz.set_xlabel(FigLbl.etaLabel) axviscz.invert_yaxis() axviscz.set_xscale('log') axviscz.set_ylim([zMax, 0]) + axviscz.tick_params(axis='both', which='major', labelsize=Style.TS_ticks) axes.append(axviscz) if Style.GRIDS: @@ -464,8 +477,9 @@ def PlotHydrosphereProps(PlanetList, Params): axv[2].plot(VSice, Planet.z_m[indsHydro]/1e3, color=thisColor, linewidth=Style.LW_sound, linestyle=Style.LS[Planet.Ocean.comp]) - + if DO_SEISMIC_PROPS: + z_vals = Planet.z_m[indsHydro]/1e3 # Plot seismic properties (GS, KS) vs. depth in hydrosphere # Safely concatenate indices, handling cases where one might be empty if np.size(indsIce) > 0 and np.size(indsLiq) > 0: @@ -478,7 +492,6 @@ def PlotHydrosphereProps(PlanetList, Params): # Use all hydrosphere indices as fallback indsHydro = np.arange(Planet.Steps.nHydro) - z_vals = Planet.z_m[indsHydro]/1e3 # Plot bulk modulus KS and shear modulus GS if seismic calculations were done if Params.CALC_SEISMIC: @@ -517,6 +530,7 @@ def PlotHydrosphereProps(PlanetList, Params): color=thisColor, linewidth=thisLW, linestyle=Style.LS[Planet.Ocean.comp]) if DO_VISCOSITY: + z_vals = Planet.z_m[indsHydro]/1e3 # Plot viscosity vs. depth in hydrosphere # Safely concatenate indices, handling cases where one might be empty if np.size(indsIce) > 0 and np.size(indsLiq) > 0: @@ -607,7 +621,7 @@ def PlotHydrosphereProps(PlanetList, Params): axviscz.set_ylim(bottom=zMax) plt.tight_layout() - fig.savefig(Params.FigureFiles.vhydro, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(Params.FigureFiles.vhydro, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent = FigMisc.TRANSPARENT) log.debug(f'Hydrosphere plot saved to file: {Params.FigureFiles.vhydro}') plt.close() @@ -650,7 +664,7 @@ def PlotCoreTradeoff(PlanetList, Params): ax.legend() plt.tight_layout() - fig.savefig(Params.FigureFiles.vcore, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(Params.FigureFiles.vcore, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Core trade plot saved to file: {Params.FigureFiles.vcore}') plt.close() @@ -724,7 +738,7 @@ def PlotSilTradeoff(PlanetList, Params): ax.legend() plt.tight_layout() - fig.savefig(Params.FigureFiles.vmant, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(Params.FigureFiles.vmant, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Mantle trade plot saved to file: {Params.FigureFiles.vmant}') plt.close() @@ -780,14 +794,15 @@ def PlotPorosity(PlanetList, Params): ax.set_xlim(left=0) # Prevent uniform porosity from overlapping the right border - phiMax = np.max(Planet.phiPlot) - if phiMax - np.min(Planet.phiPlot) < 0.05 and phiMax > 0.15: - ax.set_xlim(right=np.minimum(phiMax*1.3, 1.0)) + if len(Planet.phiPlot) > 0: + phiMax = np.max(Planet.phiPlot) + if phiMax - np.min(Planet.phiPlot) < 0.05 and phiMax > 0.15: + ax.set_xlim(right=np.minimum(phiMax*1.3, 1.0)) if Params.LEGEND: ax.legend() - fig.savefig(Params.FigureFiles.vporeDbl, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(Params.FigureFiles.vporeDbl, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Porosity plot (dual axis) saved to file: {Params.FigureFiles.vporeDbl}') plt.close() @@ -811,7 +826,7 @@ def PlotPorosity(PlanetList, Params): fig.suptitle(FigLbl.poreCompareTitle) for Planet in PlanetList: - if Planet.Do.POROUS_ROCK or Planet.Do.POROUS_ICE: + if (Planet.Do.POROUS_ROCK or Planet.Do.POROUS_ICE) and len(Planet.phiPlot) > 0: legLbl = Planet.label if (not Params.ALL_ONE_BODY) and FigLbl.BODYNAME_IN_LABEL: legLbl = f'{Planet.name} {legLbl}' @@ -826,15 +841,16 @@ def PlotPorosity(PlanetList, Params): # Prevent uniform porosity from overlapping the right border for Planet in PlanetList: - phiMax = np.max(Planet.phiPlot) - if phiMax - np.min(Planet.phiPlot) < 0.05 and phiMax > 0.15: - [ax.set_xlim(right=np.minimum(phiMax*1.3, 1.0)) for ax in axes] + if len(Planet.phiPlot) > 0: + phiMax = np.max(Planet.phiPlot) + if phiMax - np.min(Planet.phiPlot) < 0.05 and phiMax > 0.15: + [ax.set_xlim(right=np.minimum(phiMax*1.3, 1.0)) for ax in axes] if Params.LEGEND: axes[1].legend() plt.tight_layout() - fig.savefig(Params.FigureFiles.vpore, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(Params.FigureFiles.vpore, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Porosity plot saved to file: {Params.FigureFiles.vpore}') plt.close() @@ -873,7 +889,7 @@ def PlotViscosity(PlanetList, Params): plt.tight_layout() fig.savefig(Params.FigureFiles.vvisc, format=FigMisc.figFormat, dpi=FigMisc.dpi, - metadata=FigLbl.meta) + metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Viscosity plot saved to file: {Params.FigureFiles.vvisc}') plt.close() @@ -949,7 +965,7 @@ def PlotSeismic(PlanetList, Params): [ax.set_ylim(bottom=0) for ax in axf] plt.tight_layout() - fig.savefig(Params.FigureFiles.vseis, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(Params.FigureFiles.vseis, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Seismic plot saved to file: {Params.FigureFiles.vseis}') plt.close() @@ -1319,1179 +1335,227 @@ def PlotWedge(PlanetList, Params): ax.set_aspect('equal') fig.tight_layout() - fig.savefig(Params.FigureFiles.vwedg, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) + fig.savefig(Params.FigureFiles.vwedg, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) log.debug(f'Wedge plot saved to file: {Params.FigureFiles.vwedg}') plt.close() return -def PlotExploreOgram(ExplorationList, Params): - """ For plotting points showing the various models used in making - exploreogram plots. - """ - - FigLbl.SetExploration(ExplorationList[0].bodyname, ExplorationList[0].xName, - ExplorationList[0].yName, ExplorationList[0].zName) - if not FigMisc.TEX_INSTALLED: - FigLbl.StripLatex() - - for Exploration in ExplorationList: - fig = plt.figure(figsize=FigSize.explore) - grid = GridSpec(1, 1) - ax = fig.add_subplot(grid[0, 0]) - if Style.GRIDS: - ax.grid() - ax.set_axisbelow(True) - - if Exploration.zName == 'CMR2calc': - FigLbl.SetExploreTitle(Exploration.bodyname, Exploration.zName, Exploration.CMR2str) - - if Params.TITLES: - fig.suptitle(FigLbl.explorationTitle) - ax.set_xlabel(FigLbl.xLabelExplore) - ax.set_ylabel(FigLbl.yLabelExplore) - ax.set_xscale(FigLbl.xScaleExplore) - ax.set_yscale(FigLbl.yScaleExplore) +def PlotHydrosphereThermodynamics(PlanetList, Params): + """ + Plot thermodynamic properties (temperature, pressure, density, thermal expansion coefficient, + and specific heat capacity) as functions of depth in the hydrosphere. + + Layout: 2x6 grid + - Top row (3 rows each): Temperature and Pressure profiles + - Bottom row (2 rows each): Density, Alpha, and Cp + """ + + # Generate canvas and add labels - 2x6 grid + fig = plt.figure(figsize=FigSize.vhydroThermo) + grid = GridSpec(6, 6) + + # Top row: Temperature and Pressure (each spanning 3 rows) + axTz = fig.add_subplot(grid[0:3, 0:3]) # Temperature: top 3 rows, left 3 columns + axPz = fig.add_subplot(grid[0:3, 3:6]) # Pressure: top 3 rows, right 3 columns + + # Bottom row: Density, Alpha, Cp (each spanning 2 rows) + axrho = fig.add_subplot(grid[3:5, 0:2]) # Density: bottom 2 rows, first 2 columns + axalpha = fig.add_subplot(grid[3:5, 2:4]) # Alpha: bottom 2 rows, middle 2 columns + axCp = fig.add_subplot(grid[3:5, 4:6]) # Cp: bottom 2 rows, last 2 columns + + # Set labels + axTz.set_xlabel(FigLbl.Tlabel) + axTz.set_ylabel(FigLbl.zLabel) + axTz.invert_yaxis() + + axPz.set_xlabel(FigLbl.PlabelHydro) + axPz.set_ylabel(FigLbl.zLabel) + axPz.invert_yaxis() + + axrho.set_xlabel(FigLbl.rhoLabel) + axrho.set_ylabel(FigLbl.zLabel) + axrho.invert_yaxis() + + axalpha.set_xlabel(FigLbl.alphaLabel) + axalpha.set_ylabel(FigLbl.zLabel) + axalpha.invert_yaxis() + + axCp.set_xlabel(FigLbl.CpLabel) + axCp.set_ylabel(FigLbl.zLabel) + axCp.invert_yaxis() + + # Set y-axis limits based on maximum depth + zMax = np.max([Planet.z_m[Planet.Steps.nHydro-1]/1e3 for Planet in PlanetList if not Planet.Do.NO_H2O], initial=0) * 1.05 + [ax.set_ylim([zMax, 0]) for ax in [axTz, axPz, axrho, axalpha, axCp]] + + axes = [axTz, axPz, axrho, axalpha, axCp] + + if Style.GRIDS: + [ax.grid() for ax in axes] + [ax.set_axisbelow(True) for ax in axes] - x = Exploration.__getattribute__(Exploration.xName) - if np.issubdtype(x.dtype, np.number): - x = x * FigLbl.xMultExplore - else: - x = np.zeros(x.shape) - # Loop over each row and assign the same increasing integer - for i in range(x.shape[0]): - x[i, :] = i - y = Exploration.__getattribute__(Exploration.yName) - if np.issubdtype(y.dtype, np.number): - y = y * FigLbl.yMultExplore - else: - y = np.zeros(y.shape) - # Loop over each row and assign the same increasing integer - for i in range(y.shape[1]): - y[:, i] = i - z = Exploration.__getattribute__(Exploration.zName) * FigLbl.zMultExplore - - ax.set_xlim([np.min(x), np.max(x)]) - ax.set_ylim([np.min(y), np.max(y)]) - # Only keep data points for which a valid model was determined - zShape = np.shape(z) - z = np.reshape(z, -1).astype(np.float64) - INVALID = np.logical_not(np.reshape(Exploration.VALID, -1)) - z[INVALID] = np.nan - # Return data to original organization - z = np.reshape(z, zShape) - - # Calculate valid data range for colorbar - zValid = z[z == z] # Exclude NaNs - if np.size(zValid) > 0: - vmin = np.min(zValid) - vmax = np.max(zValid) + if Params.TITLES: + if Params.ALL_ONE_BODY: + fig.suptitle(f'{PlanetList[0].name}{FigLbl.hydroThermoTitle}', fontsize=FigLbl.hydroTitleSize) else: - vmin = vmax = None - - mesh = ax.pcolormesh(x, y, z, shading='auto', cmap=Color.cmap['default'], - vmin=vmin, vmax=vmax, rasterized=FigMisc.PT_RASTER) - cont = ax.contour(x, y, z, colors='black') - lbls = plt.clabel(cont, fmt=FigLbl.cfmt) - cbar = fig.colorbar(mesh, ax=ax, format=FigLbl.cbarFmt) - # Add the min and max values to the colorbar for reading convenience - # We compare z values to z values to exclude nans from the max finding, - # exploiting the fact that nan == nan is False. - if np.size(zValid) > 0: - mesh.set_clim(vmin=vmin, vmax=vmax) - # Filter existing ticks to only include those within valid data range - existing_ticks = cbar.get_ticks() - valid_ticks = existing_ticks[(existing_ticks >= vmin) & (existing_ticks <= vmax)] - # Add min and max values to the filtered ticks - new_ticks = np.insert(np.append(valid_ticks, vmax), 0, vmin) - cbar.set_ticks(np.unique(new_ticks)) - cbar.set_label(FigLbl.cbarLabelExplore, size=12) - - plt.tight_layout() - fig.savefig(Params.FigureFiles.explore, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) - log.debug(f'Plot saved to file: {Params.FigureFiles.explore}') - plt.close() - - # Plot combination - if Params.COMPARE and np.size(ExplorationList) > 1: - Exploration = ExplorationList[0] - fig = plt.figure(figsize=FigSize.explore) - grid = GridSpec(1, 1) - ax = fig.add_subplot(grid[0, 0]) - if Style.GRIDS: - ax.grid() - ax.set_axisbelow(True) + fig.suptitle(FigLbl.hydroThermoCompareTitle, fontsize=FigLbl.hydroTitleSize) - if Params.TITLES: - fig.suptitle(FigLbl.explorationTitle) - ax.set_xlabel(FigLbl.xLabelExplore) - ax.set_ylabel(FigLbl.yLabelExplore) - ax.set_xscale(FigLbl.xScaleExplore) - ax.set_yscale(FigLbl.yScaleExplore) - - # Initialize with first exploration's data - initial_x_values = Exploration.__getattribute__(Exploration.xName) - if np.issubdtype(initial_x_values.dtype, np.number): - x = initial_x_values * FigLbl.xMultExplore - else: - x = np.zeros(initial_x_values.shape) - # Loop over each row and assign the same increasing integer - for i in range(initial_x_values.shape[0]): - x[i, :] = i - initial_y_values = Exploration.__getattribute__(Exploration.yName) - if np.issubdtype(initial_y_values.dtype, np.number): - y = initial_y_values * FigLbl.yMultExplore - else: - y = np.zeros(initial_y_values.shape) - # Loop over each row and assign the same increasing integer - for i in range(initial_y_values.shape[1]): - y[:, i] = i - z = Exploration.__getattribute__(Exploration.zName) * FigLbl.zMultExplore - # Only keep data points for which a valid model was determined - zShape = np.shape(z) - z = np.reshape(z, -1).astype(np.float64) - INVALID = np.logical_not(np.reshape(Exploration.VALID, -1)) - z[INVALID] = np.nan - # Return data to original organization - z = np.reshape(z, zShape) - - for Exploration in ExplorationList[1:]: - # Get new exploration data - x_values = Exploration.__getattribute__(Exploration.xName) - if np.issubdtype(x_values.dtype, np.number): - new_x = x_values * FigLbl.xMultExplore - else: - new_x = np.zeros(x_values.shape) - for i in range(x_values.shape[0]): - new_x[i, :] = i - - y_values = Exploration.__getattribute__(Exploration.yName) - if np.issubdtype(y_values.dtype, np.number): - new_y = y_values * FigLbl.yMultExplore - else: - new_y = np.zeros(y_values.shape) - for i in range(y_values.shape[1]): - new_y[:, i] = i - - thisz = Exploration.__getattribute__(Exploration.zName) * FigLbl.zMultExplore - # Only keep data points for which a valid model was determined - zShape = np.shape(thisz) - thisz = np.reshape(thisz, -1).astype(np.float64) - INVALID = np.logical_not(np.reshape(Exploration.VALID, -1)) - thisz[INVALID] = np.nan - # Return data to original organization - thisz = np.reshape(thisz, zShape) - - # Determine concatenation axis based on which dimension matches - if new_x.shape[0] == x.shape[0] and ( - (np.issubdtype(x_values.dtype, np.number) and np.allclose(x_values[:, 0], initial_x_values[:, 0], equal_nan=True)) or - (not np.issubdtype(x_values.dtype, np.number) and np.array_equal(x_values[:, 0], initial_x_values[:, 0])) - ): - # x arrays match, concatenate along y-axis (axis=1) - # Find insertion point to maintain monotonic order - y_existing = y[0, :] - y_new_start = new_y[0, 0] - insert_idx = np.searchsorted(y_existing, y_new_start) - # Insert new data at the appropriate position - x = np.concatenate([x[:, :insert_idx], new_x, x[:, insert_idx:]], axis=1) - y = np.concatenate([y[:, :insert_idx], new_y, y[:, insert_idx:]], axis=1) - z = np.concatenate([z[:, :insert_idx], thisz, z[:, insert_idx:]], axis=1) - elif new_y.shape[1] == y.shape[1] and ( - (np.issubdtype(y_values.dtype, np.number) and np.allclose(y_values[0, :], initial_y_values[0, :], equal_nan=True)) or - (not np.issubdtype(y_values.dtype, np.number) and np.array_equal(y_values[0, :], initial_y_values[0, :])) - ): - # y arrays match, concatenate along x-axis (axis=0) - # Find insertion point to maintain monotonic order - x_existing = x[:, 0] - x_new_start = new_x[0, 0] - insert_idx = np.searchsorted(x_existing, x_new_start) - # Insert new data at the appropriate position - x = np.concatenate([x[:insert_idx, :], new_x, x[insert_idx:, :]], axis=0) - y = np.concatenate([y[:insert_idx, :], new_y, y[insert_idx:, :]], axis=0) - z = np.concatenate([z[:insert_idx, :], thisz, z[insert_idx:, :]], axis=0) - else: - log.warning('The exploreogram comparison plot cannot be made because the x or y axes are not the same. Skipping the comparison plot.') - break - - # Calculate valid data range for colorbar - zValid = z[z == z] # Exclude NaNs - if np.size(zValid) > 0: - vmin = np.min(zValid) - vmax = np.max(zValid) + # Get unique compositions for reference profiles + comps = [] + for Planet in PlanetList: + if "CustomSolution" in Planet.Ocean.comp: + if Planet.Ocean.comp.split('=')[0] not in comps: + comps.append(Planet.Ocean.comp) else: - vmin = vmax = None - - mesh = ax.pcolormesh(x, y, z, shading='auto', cmap=Color.cmap['default'], - vmin=vmin, vmax=vmax, rasterized=FigMisc.PT_RASTER) - cont = ax.contour(x, y, z, colors='black') - lbls = plt.clabel(cont, fmt=FigLbl.cfmt) - cbar = fig.colorbar(mesh, ax=ax, format=FigLbl.cbarFmt) - # Add the min and max values to the colorbar for reading convenience - # We compare z values to z values to exclude nans from the max finding, - # exploiting the fact that nan == nan is False. - if np.size(zValid) > 0: - mesh.set_clim(vmin=vmin, vmax=vmax) - # Filter existing ticks to only include those within valid data range - existing_ticks = cbar.get_ticks() - valid_ticks = existing_ticks[(existing_ticks >= vmin) & (existing_ticks <= vmax)] - # Add min and max values to the filtered ticks - new_ticks = np.insert(np.append(valid_ticks, vmax), 0, vmin) - cbar.set_ticks(np.unique(new_ticks)) - cbar.set_label(FigLbl.cbarLabelExplore, size=12) - - plt.tight_layout() - fig.savefig(Params.FigureFiles.explore, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) - log.debug(f'Plot saved to file: {Params.FigureFiles.explore}') - plt.close() - - return - - -def PlotExploreOgramDsigma(ExplorationList, Params): - """ Plot a scatter showing the evaluated ocean mean conductivity and layer thickness, - for comparison against canonical D/sigma exploration plots. - """ + comps.append(Planet.Ocean.comp) + comps = np.unique(comps) - ExplorationList[0].xName = 'D_km' - ExplorationList[0].yName = 'sigmaMean_Sm' - ExplorationList[0].zName = 'zb_km' - FigLbl.SetExploration(ExplorationList[0].bodyname, ExplorationList[0].xName, - ExplorationList[0].yName, ExplorationList[0].zName) - if not FigMisc.TEX_INSTALLED: - FigLbl.StripLatex() + # Plot reference profiles first (if enabled) + if Params.PLOT_REF: + # Keep track of which reference profiles have been plotted so that we do each only once + newRef = {comp:True for comp in comps} - for Exploration in (ex for ex in ExplorationList if not ex.NO_H2O): - Exploration.xName = 'D_km' - Exploration.yName = 'sigmaMean_Sm' - Exploration.zName = 'zb_km' - fig = plt.figure(figsize=FigSize.explore) - grid = GridSpec(1, 1) - ax = fig.add_subplot(grid[0, 0]) - if Style.GRIDS: - ax.grid() - ax.set_axisbelow(True) + # Get max pressure among all profiles so we know how far out to plot refs + Plist = np.concatenate([Planet.P_MPa[:Planet.Steps.nHydro] for Planet in PlanetList]) + Pmax_MPa = np.max(Plist) - if Params.TITLES: - fig.suptitle(FigLbl.explorationDsigmaTitle) - ax.set_xlabel(FigLbl.xLabelExplore) - ax.set_ylabel(FigLbl.yLabelExplore) - # Override standard settings for this type of plot - ax.set_xscale('linear') - ax.set_yscale('log') - ax.set_ylim(FigMisc.DSIGMA_YLIMS) - - x = np.reshape(Exploration.__getattribute__(Exploration.xName) * FigLbl.xMultExplore, -1) - y = np.reshape(Exploration.__getattribute__(Exploration.yName) * FigLbl.yMultExplore, -1) - # Only keep data points for which a valid model was determined - VALID = np.logical_not(np.logical_or(np.isnan(x), np.isnan(y))) - x = x[VALID] - y = y[VALID] - if np.size(x) > 0: - ax.set_xlim([np.min(x), np.max(x)]) - linzb = np.reshape(Exploration.zb_km, -1)[VALID] - - # Get ocean composition for composition line drawing - ocean_comp = np.reshape(Exploration.oceanComp, -1)[VALID] - - # Draw composition lines if enabled - if FigMisc.DRAW_COMPOSITION_LINE: - # Group by unique ocean compositions and plot connecting lines - unique_comps = np.unique(ocean_comp) - for comp in unique_comps: - comp_indices = np.where(ocean_comp == comp)[0] - x_line, y_line = x[comp_indices], y[comp_indices] - - # Sort points by x for connected lines - sorted_idx = np.argsort(x_line) - x_line = x_line[sorted_idx] - y_line = y_line[sorted_idx] - - # Set color based on composition - if FigMisc.MANUAL_HYDRO_COLORS: - thisColor = Color.cmap[comp](Color.GetNormT(np.nanmax(linzb[comp_indices]))) - else: - thisColor = Color.cmap[comp](0.5) - - # Clean composition label - if 'CustomSolution' in comp: - comp_label = comp.split('=')[0].replace('CustomSolution', '') - else: - comp_label = comp - - # Plot line for this composition - ax.plot(x_line, y_line, color=thisColor, linewidth=FigMisc.DSIGMA_COMP_LINE_WIDTH, - alpha=FigMisc.DSIGMA_COMP_LINE_ALPHA, label=f'{comp_label}', zorder=2) - - # Handle ice thickness coloring or regular colorbar - if FigMisc.SHOW_ICE_THICKNESS_DOTS: - # Get ice shell thickness and normalize for coloring - ice_thickness = np.reshape(Exploration.zb_km, -1)[VALID] - if np.size(ice_thickness) > 0: - # Set bounds for normalization (min and max of ice thickness) - Tbound_lower = np.min(ice_thickness) - Tbound_upper = np.max(ice_thickness) - # Get normalized values using GetNormT - norm_thickness = Color.GetNormT(ice_thickness, Tbound_lower, Tbound_upper) - else: - norm_thickness = None - - # Plot scatter with ice thickness coloring - pts = ax.scatter(x, y, c=norm_thickness, - cmap=FigMisc.DSIGMA_ICE_THICKNESS_CMAP, marker=Style.MS_Induction, - s=Style.MW_Induction**2, edgecolors=FigMisc.DSIGMA_DOT_EDGE_COLOR, - linewidths=FigMisc.DSIGMA_DOT_EDGE_WIDTH, zorder=3) - - # Create legend showing ice thickness values instead of colorbar - if np.size(ice_thickness) > 0: - # Round thickness values to avoid floating-point duplicates - rounded_thicknesses = np.round(ice_thickness) - unique_thicknesses = np.unique(rounded_thicknesses) - - if len(unique_thicknesses) > 10: # Limit number of legend entries - # Select representative values - indices = np.linspace(0, len(unique_thicknesses)-1, 10, dtype=int) - selected_thicknesses = unique_thicknesses[indices] + for Planet in PlanetList: + if newRef[Planet.Ocean.comp] and Planet.Ocean.comp != 'none': + # Get strings for referencing and labeling + # If using CustomSolution, then adjust label so compatible with Latex formating + if "CustomSolution" in Planet.Ocean.comp: + wList = f"$\\rho_\mathrm{{melt}}$ \ce{{{Planet.Ocean.comp.split('=')[0].strip()}}} \\{{" else: - selected_thicknesses = unique_thicknesses - - # Create legend elements - legend_elements = [] - for thickness in selected_thicknesses: - norm_val = Color.GetNormT(thickness, Tbound_lower, Tbound_upper) - color = plt.cm.Greys(norm_val) - legend_elements.append(plt.Line2D([0], [0], marker='o', color='w', - markerfacecolor=color, markeredgecolor='black', - markersize=8, label=f'{thickness:.0f} km')) - - # Add legend for ice thickness - thickness_legend = ax.legend(handles=legend_elements, title="Ice Shell Thickness", - loc='upper right', bbox_to_anchor=(1.0, 1.0), - fontsize=8, title_fontsize=10) - ax.add_artist(thickness_legend) # Keep this legend when adding composition legend - else: - # Standard colorbar approach - pts = ax.scatter(x, y, c=linzb, - cmap=Color.cmap[Exploration.oceanComp[0,0]], - marker=Style.MS_Induction, s=Style.MW_Induction**2, zorder=3) - - cbar = fig.colorbar(pts, ax=ax, format=FigLbl.cbarFmt) - # Append the max value to the colorbar for reading convenience - # We compare z values to z values to exclude nans from the max finding, - # exploiting the fact that nan == nan is False. - if np.size(linzb) > 0: - new_ticks = np.insert(np.append(cbar.get_ticks(), np.max(linzb)), 0, np.min(linzb)) - cbar.set_ticks(np.unique(new_ticks)) - cbar.set_label(FigLbl.cbarLabelExplore, size=12) - - # Add legend for composition lines if enabled - if FigMisc.DRAW_COMPOSITION_LINE and Params.LEGEND: - if FigMisc.SHOW_ICE_THICKNESS_DOTS: - # Position composition legend to avoid overlap with ice thickness legend - ax.legend(title="Ocean Composition", fontsize=FigMisc.DSIGMA_COMP_LEGEND_FONT_SIZE, - title_fontsize=FigMisc.DSIGMA_COMP_LEGEND_TITLE_SIZE, - loc='upper left', bbox_to_anchor=(0.0, 1.0)) - else: - ax.legend(title="Ocean Composition", fontsize=FigMisc.DSIGMA_COMP_LEGEND_FONT_SIZE, - title_fontsize=FigMisc.DSIGMA_COMP_LEGEND_TITLE_SIZE) - - plt.tight_layout() - fig.savefig(Params.FigureFiles.exploreDsigma, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) - log.debug(f'Plot saved to file: {Params.FigureFiles.exploreDsigma}') - plt.close() + wList = f"$\\rho_\mathrm{{melt}}$ \ce{{{Planet.Ocean.comp}}} \\{{" + wList += ', '.join([f'{w*FigLbl.wMult:.0f}' for w in Params.wRef_ppt[Planet.Ocean.comp]]) + wList += '\}\,$\si{' + FigLbl.wUnits + '}$' + if not FigMisc.TEX_INSTALLED: + wList = FigLbl.StripLatexFromString(wList) + # Take care to only plot the values consistent with layer solutions + iPlot = Params.Pref_MPa[Planet.Ocean.comp] < Pmax_MPa + # Plot all reference melting curve densities + for i in range(Params.nRef[Planet.Ocean.comp]): + thisRef, = axrho.plot(Params.rhoRef_kgm3[Planet.Ocean.comp][i,iPlot], + Params.Pref_MPa[Planet.Ocean.comp][iPlot]*FigLbl.PmultHydro, + color=Color.ref, + lw=Style.LW_ref, + ls=Style.LS_ref[Planet.Ocean.comp]) + if FigMisc.REFS_IN_LEGEND and i == 0: thisRef.set_label(wList) + newRef[Planet.Ocean.comp] = False - # Plot combination - if Params.COMPARE and np.size(ExplorationList) > 1: - # Filter out explorations with no H2O - ValidExplorations = [ex for ex in ExplorationList if not ex.NO_H2O] - - if len(ValidExplorations) > 1: - # Set up exploration parameters for the first valid exploration - Exploration = ValidExplorations[0] - Exploration.xName = 'D_km' - Exploration.yName = 'sigmaMean_Sm' - Exploration.zName = 'zb_km' - FigLbl.SetExploration(Exploration.bodyname, Exploration.xName, - Exploration.yName, Exploration.zName) - if not FigMisc.TEX_INSTALLED: - FigLbl.StripLatex() + # Get min and max salinities and temps for each comp for scaling + wMinMax_ppt = {} + TminMax_K = {} + if FigMisc.SCALE_HYDRO_LW or FigMisc.MANUAL_HYDRO_COLORS: + for comp in comps: + if comp != 'none': + wAll_ppt = [Planet.Ocean.wOcean_ppt for Planet in PlanetList if Planet.Ocean.comp == comp] + wMinMax_ppt[comp] = [np.min(wAll_ppt), np.max(wAll_ppt)] + Tall_K = [Planet.Bulk.Tb_K for Planet in PlanetList if Planet.Ocean.comp == comp] + TminMax_K[comp] = [np.min(Tall_K), np.max(Tall_K)] + # Reset to default if all models are the same or if desired + if not FigMisc.RELATIVE_Tb_K or TminMax_K[comp][0] == TminMax_K[comp][1]: + TminMax_K[comp] = Color.Tbounds_K - fig = plt.figure(figsize=FigSize.explore) - grid = GridSpec(1, 1) - ax = fig.add_subplot(grid[0, 0]) - if Style.GRIDS: - ax.grid() - ax.set_axisbelow(True) - - if Params.TITLES: - fig.suptitle(FigLbl.explorationDsigmaTitle) - ax.set_xlabel(FigLbl.xLabelExplore) - ax.set_ylabel(FigLbl.yLabelExplore) - # Override standard settings for this type of plot - ax.set_xscale('linear') - ax.set_yscale('log') - ax.set_ylim(FigMisc.DSIGMA_YLIMS) - - # Collect data from all explorations - all_x = [] - all_y = [] - all_zb = [] - all_ocean_comp = [] - - for Exploration in ValidExplorations: - x = np.reshape(Exploration.__getattribute__(Exploration.xName) * FigLbl.xMultExplore, -1) - y = np.reshape(Exploration.__getattribute__(Exploration.yName) * FigLbl.yMultExplore, -1) - # Only keep data points for which a valid model was determined - VALID = np.logical_not(np.logical_or(np.isnan(x), np.isnan(y))) - x, y = x[VALID], y[VALID] - - # Get zb_km and ocean composition data - zb = np.reshape(Exploration.zb_km, -1)[VALID] - ocean_comp = np.reshape(Exploration.oceanComp, -1)[VALID] - - all_x.extend(x) - all_y.extend(y) - all_zb.extend(zb) - all_ocean_comp.extend(ocean_comp) - - # Convert to numpy arrays - all_x = np.array(all_x) - all_y = np.array(all_y) - all_zb = np.array(all_zb) - all_ocean_comp = np.array(all_ocean_comp) - - # Draw composition lines if enabled - if FigMisc.DRAW_COMPOSITION_LINE: - # Group by unique ocean compositions and plot connecting lines - unique_comps = np.unique(all_ocean_comp) - plotted_labels = set() # Keep track of which labels have been plotted - - for comp in unique_comps: - comp_indices = np.where(all_ocean_comp == comp)[0] - x_line, y_line = all_x[comp_indices], all_y[comp_indices] - - # Sort points by x for connected lines - sorted_idx = np.argsort(x_line) - x_line = x_line[sorted_idx] - y_line = y_line[sorted_idx] - - # Set color based on composition - if FigMisc.MANUAL_HYDRO_COLORS: - thisColor = Color.cmap[comp](Color.GetNormT(np.nanmax(all_zb[comp_indices]))) - else: - thisColor = Color.cmap[comp](0.5) - - # Clean composition label - if 'CustomSolution' in comp: - comp_label = comp.split('=')[0].replace('CustomSolution', '') - else: - comp_label = comp - - # Only add label if this composition hasn't been plotted yet - line_label = comp_label if comp_label not in plotted_labels else None - if line_label: - plotted_labels.add(comp_label) - - # Plot line for this composition - ax.plot(x_line, y_line, color=thisColor, linewidth=FigMisc.DSIGMA_COMP_LINE_WIDTH, - alpha=FigMisc.DSIGMA_COMP_LINE_ALPHA, label=line_label, zorder=2) + # Now plot all profiles together + for i, Planet in enumerate(PlanetList): + # This is a hydrosphere-only plot, so skip waterless bodies + if Planet.Ocean.comp != 'none': + legLbl = Planet.label + if (not Params.ALL_ONE_BODY) and FigLbl.BODYNAME_IN_LABEL: + legLbl = f'{Planet.name} {legLbl}' - # Plot scatter points using the same marker for all explorations - if np.size(all_x) > 0: - ax.set_xlim([np.min(all_x), np.max(all_x)]) - - # Handle ice thickness coloring or regular colorbar for combined plot - if FigMisc.SHOW_ICE_THICKNESS_DOTS: - # Get ice shell thickness and normalize for coloring - if np.size(all_zb) > 0: - # Set bounds for normalization (min and max of ice thickness across all explorations) - Tbound_lower = np.min(all_zb) - Tbound_upper = np.max(all_zb) - # Get normalized values using GetNormT - norm_thickness = Color.GetNormT(all_zb, Tbound_lower, Tbound_upper) - else: - norm_thickness = None - - # Plot scatter with ice thickness coloring - pts = ax.scatter(all_x, all_y, c=norm_thickness, - cmap=FigMisc.DSIGMA_ICE_THICKNESS_CMAP, marker=Style.MS_Induction, - s=Style.MW_Induction**2, edgecolors=FigMisc.DSIGMA_DOT_EDGE_COLOR, - linewidths=FigMisc.DSIGMA_DOT_EDGE_WIDTH, zorder=3) - - # Create legend showing ice thickness values instead of colorbar - if np.size(all_zb) > 0: - # Round thickness values to avoid floating-point duplicates - rounded_thicknesses = np.round(all_zb) - unique_thicknesses = np.unique(rounded_thicknesses) - - if len(unique_thicknesses) > FigMisc.DSIGMA_MAX_LEGEND_ENTRIES: # Limit number of legend entries - # Select representative values - indices = np.linspace(0, len(unique_thicknesses)-1, FigMisc.DSIGMA_MAX_LEGEND_ENTRIES, dtype=int) - selected_thicknesses = unique_thicknesses[indices] - else: - selected_thicknesses = unique_thicknesses - - # Create legend elements - legend_elements = [] - for thickness in selected_thicknesses: - norm_val = Color.GetNormT(thickness, Tbound_lower, Tbound_upper) - color = getattr(plt.cm, FigMisc.DSIGMA_ICE_THICKNESS_CMAP)(norm_val) - legend_elements.append(plt.Line2D([0], [0], marker='o', color='w', - markerfacecolor=color, markeredgecolor=FigMisc.DSIGMA_DOT_EDGE_COLOR, - markersize=8, label=f'{thickness:.0f}')) - - # Add legend for ice thickness - thickness_legend = ax.legend(handles=legend_elements, title="Ice Shell Thickness (km)", - loc='upper right', bbox_to_anchor=(1.0, 1.0), - fontsize=FigMisc.DSIGMA_ICE_LEGEND_FONT_SIZE, title_fontsize=FigMisc.DSIGMA_ICE_LEGEND_TITLE_SIZE) - ax.add_artist(thickness_legend) # Keep this legend when adding composition legend + # Set style options + if FigMisc.MANUAL_HYDRO_COLORS: + Color.Tbounds_K = TminMax_K[Planet.Ocean.comp] + thisColor = Color.cmap[Planet.Ocean.comp](Color.GetNormT(Planet.Bulk.Tb_K)) else: - # Standard colorbar approach - pts = ax.scatter(all_x, all_y, c=all_zb, - cmap=Color.cmap['default'], marker=Style.MS_Induction, - s=Style.MW_Induction**2, zorder=3) - - cbar = fig.colorbar(pts, ax=ax, format=FigLbl.cbarFmt) - # Append the max value to the colorbar for reading convenience - if np.size(all_zb) > 0: - new_ticks = np.insert(np.append(cbar.get_ticks(), np.max(all_zb)), 0, np.min(all_zb)) - cbar.set_ticks(np.unique(new_ticks)) - cbar.set_label(FigLbl.cbarLabelExplore, size=12) - - # Add legend for composition lines if enabled - if FigMisc.DRAW_COMPOSITION_LINE and Params.LEGEND: - if FigMisc.SHOW_ICE_THICKNESS_DOTS: - # Position composition legend to avoid overlap with ice thickness legend - ax.legend(title="Ocean Composition", fontsize=FigMisc.DSIGMA_COMP_LEGEND_FONT_SIZE, - title_fontsize=FigMisc.DSIGMA_COMP_LEGEND_TITLE_SIZE, - bbox_to_anchor=(0.0, 1.0)) - else: - ax.legend(title="Ocean Composition", fontsize=FigMisc.DSIGMA_COMP_LEGEND_FONT_SIZE, - title_fontsize=FigMisc.DSIGMA_COMP_LEGEND_TITLE_SIZE) - - plt.tight_layout() - fig.savefig(Params.FigureFiles.exploreDsigma, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) - log.debug(f'Combined D/sigma plot saved to file: {Params.FigureFiles.exploreDsigma}') - plt.close() - - return - -def PlotExploreOgramZbD(ExplorationList, Params): - """ Plot a scatter showing ice shell thickness vs ocean thickness with user-configurable z variable, - with optional ocean composition lines. - """ - - FigLbl.SetExploration(ExplorationList[0].bodyname, 'zb_km', 'D_km', ExplorationList[0].zName) - if not FigMisc.TEX_INSTALLED: - FigLbl.StripLatex() - - for Exploration in (ex for ex in ExplorationList if not ex.NO_H2O): - Exploration.xName = 'zb_km' - Exploration.yName = 'D_km' - # zName should already be set by user - - fig = plt.figure(figsize=FigSize.explore) - grid = GridSpec(1, 1) - ax = fig.add_subplot(grid[0, 0]) - if Style.GRIDS: - ax.grid() - ax.set_axisbelow(True) - - if Params.TITLES: - fig.suptitle(FigLbl.explorationTitle) - ax.set_xlabel(FigLbl.xLabelExplore) - ax.set_ylabel(FigLbl.yLabelExplore) - ax.set_xscale('linear') - ax.set_yscale('linear') - - x = np.reshape(Exploration.__getattribute__(Exploration.xName) * FigLbl.xMultExplore, -1) - y = np.reshape(Exploration.__getattribute__(Exploration.yName) * FigLbl.yMultExplore, -1) - z = np.reshape(Exploration.__getattribute__(Exploration.zName) * FigLbl.zMultExplore, -1) - # Only keep data points for which a valid model was determined - VALID = np.logical_not(np.logical_or(np.isnan(x), np.isnan(y))) - x, y, z = x[VALID], y[VALID], z[VALID] - - if np.size(x) > 0: - # Add padding to prevent marker cutoff at edges - x_range = np.max(x) - np.min(x) - y_range = np.max(y) - np.min(y) - x_padding = x_range * FigMisc.ZBD_AXIS_PADDING - y_padding = y_range * FigMisc.ZBD_AXIS_PADDING - ax.set_xlim([np.min(x) - x_padding, np.max(x) + x_padding]) - ax.set_ylim([np.min(y) - y_padding, np.max(y) + y_padding]) - - # Get ocean composition for composition line drawing - ocean_comp = np.reshape(Exploration.oceanComp, -1)[VALID] - - # Draw composition lines if enabled - if FigMisc.DRAW_COMPOSITION_LINE: - # Group by unique ocean compositions and plot connecting lines - unique_comps = np.unique(ocean_comp) - for comp in unique_comps: - comp_indices = np.where(ocean_comp == comp)[0] - x_line, y_line = x[comp_indices], y[comp_indices] - - # Sort points by x for connected lines - sorted_idx = np.argsort(x_line) - x_line = x_line[sorted_idx] - y_line = y_line[sorted_idx] - - # Set color based on composition - if FigMisc.MANUAL_HYDRO_COLORS: - thisColor = Color.cmap[comp](Color.GetNormT(np.nanmax(z[comp_indices]))) - else: - thisColor = Color.cmap[comp](0.5) - - # Clean composition label - if 'CustomSolution' in comp: - comp_label = comp.split('=')[0].replace('CustomSolution', '') - else: - comp_label = comp - - # Plot line for this composition - ax.plot(x_line, y_line, color=thisColor, linewidth=FigMisc.ZBD_COMP_LINE_WIDTH, - alpha=FigMisc.ZBD_COMP_LINE_ALPHA, label=f'{comp_label}', zorder=2) - - # Separate NaN and non-NaN points for different plotting - nan_mask = np.isnan(z) - valid_mask = ~nan_mask - - # Plot non-NaN points colored by z variable - if np.any(valid_mask): - z_valid = z[valid_mask] - pts = ax.scatter(x[valid_mask], y[valid_mask], c=z_valid, - cmap=FigMisc.ZBD_COLORMAP, marker=Style.MS_Induction, - s=Style.MW_Induction**2, edgecolors=FigMisc.ZBD_DOT_EDGE_COLOR, - linewidths=FigMisc.ZBD_DOT_EDGE_WIDTH, zorder=3, - vmin=np.nanmin(z_valid), vmax=np.nanmax(z_valid)) - - # Add colorbar - cbar = fig.colorbar(pts, ax=ax, format=FigLbl.cbarFmt) - # Extend colorbar to cover the full range of default ticks - default_ticks = cbar.get_ticks() - if len(default_ticks) > 0: - pts.set_clim(vmin=np.min(default_ticks), vmax=np.max(default_ticks)) - cbar.set_label(FigLbl.cbarLabelExplore, size=12) - - # Plot NaN points with distinct color - if np.any(nan_mask): - ax.scatter(x[nan_mask], y[nan_mask], c=FigMisc.ZBD_NAN_COLOR, - marker=FigMisc.ZBD_NAN_MARKER, s=Style.MW_Induction**2, - edgecolors=FigMisc.ZBD_DOT_EDGE_COLOR, linewidths=FigMisc.ZBD_DOT_EDGE_WIDTH, - zorder=3, label='Invalid data') - - # Add legend for composition lines if enabled - if FigMisc.DRAW_COMPOSITION_LINE and Params.LEGEND: - ax.legend(title="Ocean Composition", fontsize=FigMisc.ZBD_COMP_LEGEND_FONT_SIZE, - title_fontsize=FigMisc.ZBD_COMP_LEGEND_TITLE_SIZE) - elif np.any(nan_mask) and Params.LEGEND: - # Show legend for invalid data points if no composition lines are shown - ax.legend(fontsize=FigMisc.ZBD_COMP_LEGEND_FONT_SIZE) - - plt.tight_layout() - fig.savefig(Params.FigureFiles.exploreZbD, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) - log.debug(f'Plot saved to file: {Params.FigureFiles.exploreZbD}') - plt.close() - - # Plot combination - if Params.COMPARE and np.size(ExplorationList) > 1: - # Filter out explorations with no H2O - ValidExplorations = [ex for ex in ExplorationList if not ex.NO_H2O] - - if len(ValidExplorations) > 1: - # Set up exploration parameters for the first valid exploration - Exploration = ValidExplorations[0] - Exploration.xName = 'zb_km' - Exploration.yName = 'D_km' - # zName should already be set by user - FigLbl.SetExploration(Exploration.bodyname, Exploration.xName, - Exploration.yName, Exploration.zName) - if not FigMisc.TEX_INSTALLED: - FigLbl.StripLatex() - - fig = plt.figure(figsize=FigSize.explore) - grid = GridSpec(1, 1) - ax = fig.add_subplot(grid[0, 0]) - if Style.GRIDS: - ax.grid() - ax.set_axisbelow(True) - - if Params.TITLES: - fig.suptitle(FigLbl.explorationTitle) - ax.set_xlabel(FigLbl.xLabelExplore) - ax.set_ylabel(FigLbl.yLabelExplore) - ax.set_xscale(FigLbl.xScaleExplore) - ax.set_yscale(FigLbl.yScaleExplore) - - # Collect data from all explorations - all_x = [] - all_y = [] - all_z = [] - all_ocean_comp = [] - - for Exploration in ValidExplorations: - x = np.reshape(Exploration.__getattribute__(Exploration.xName) * FigLbl.xMultExplore, -1) - y = np.reshape(Exploration.__getattribute__(Exploration.yName) * FigLbl.yMultExplore, -1) - z = np.reshape(Exploration.__getattribute__(Exploration.zName) * FigLbl.zMultExplore, -1) - # Only keep data points for which a valid model was determined - VALID = np.logical_not(np.logical_or(np.isnan(x), np.isnan(y))) - x, y, z = x[VALID], y[VALID], z[VALID] - - if np.size(x) > 0: - ax.set_xlim([np.min(x), np.max(x)]) - ax.set_ylim([np.min(y), np.max(y)]) - - # Get ocean composition data - ocean_comp = np.reshape(Exploration.oceanComp, -1)[VALID] - - all_x.extend(x) - all_y.extend(y) - all_z.extend(z) - all_ocean_comp.extend(ocean_comp) - - # Convert to numpy arrays - all_x = np.array(all_x) - all_y = np.array(all_y) - all_z = np.array(all_z) - all_ocean_comp = np.array(all_ocean_comp) - - # Draw composition lines if enabled - if FigMisc.DRAW_COMPOSITION_LINE: - # Group by unique ocean compositions and plot connecting lines - unique_comps = np.unique(all_ocean_comp) - plotted_labels = set() # Keep track of which labels have been plotted - - for comp in unique_comps: - comp_indices = np.where(all_ocean_comp == comp)[0] - x_line, y_line = all_x[comp_indices], all_y[comp_indices] - - # Sort points by x for connected lines - sorted_idx = np.argsort(x_line) - x_line = x_line[sorted_idx] - y_line = y_line[sorted_idx] - - # Set color based on composition - if FigMisc.MANUAL_HYDRO_COLORS: - thisColor = Color.cmap[comp](Color.GetNormT(np.nanmax(all_z[comp_indices]))) - else: - thisColor = Color.cmap[comp](0.5) - - # Clean composition label - if 'CustomSolution' in comp: - comp_label = comp.split('=')[0].replace('CustomSolution', '') - else: - comp_label = comp - - # Only add label if this composition hasn't been plotted yet - line_label = comp_label if comp_label not in plotted_labels else None - if line_label: - plotted_labels.add(comp_label) - - # Plot line for this composition - ax.plot(x_line, y_line, color=thisColor, linewidth=FigMisc.ZBD_COMP_LINE_WIDTH, - alpha=FigMisc.ZBD_COMP_LINE_ALPHA, label=line_label, zorder=2) - - # Plot scatter points using combined data - if np.size(all_x) > 0: - # Add padding to prevent marker cutoff at edges - x_range = np.max(all_x) - np.min(all_x) - y_range = np.max(all_y) - np.min(all_y) - x_padding = x_range * FigMisc.ZBD_AXIS_PADDING - y_padding = y_range * FigMisc.ZBD_AXIS_PADDING - ax.set_xlim([np.min(all_x) - x_padding, np.max(all_x) + x_padding]) - ax.set_ylim([np.min(all_y) - y_padding, np.max(all_y) + y_padding]) - - # Separate NaN and non-NaN points for different plotting - nan_mask = np.isnan(all_z) - valid_mask = ~nan_mask - - # Plot non-NaN points colored by z variable - if np.any(valid_mask): - z_valid = all_z[valid_mask] - pts = ax.scatter(all_x[valid_mask], all_y[valid_mask], c=z_valid, - cmap=FigMisc.ZBD_COLORMAP, marker=Style.MS_Induction, - s=Style.MW_Induction**2, edgecolors=FigMisc.ZBD_DOT_EDGE_COLOR, - linewidths=FigMisc.ZBD_DOT_EDGE_WIDTH, zorder=3, - vmin=np.nanmin(z_valid), vmax=np.nanmax(z_valid)) - - # Add colorbar - cbar = fig.colorbar(pts, ax=ax, format=FigLbl.cbarFmt) - # Extend colorbar to cover the full range of default ticks - default_ticks = cbar.get_ticks() - if len(default_ticks) > 0: - pts.set_clim(vmin=np.min(default_ticks), vmax=np.max(default_ticks)) - cbar.set_label(FigLbl.cbarLabelExplore, size=12) - - # Plot NaN points with distinct color - if np.any(nan_mask): - ax.scatter(all_x[nan_mask], all_y[nan_mask], c=FigMisc.ZBD_NAN_COLOR, - marker=FigMisc.ZBD_NAN_MARKER, s=Style.MW_Induction**2, - edgecolors=FigMisc.ZBD_DOT_EDGE_COLOR, linewidths=FigMisc.ZBD_DOT_EDGE_WIDTH, - zorder=3, label='Invalid data') - - # Add legend for composition lines if enabled - if FigMisc.DRAW_COMPOSITION_LINE and Params.LEGEND: - ax.legend(title="Ocean Composition", fontsize=FigMisc.ZBD_COMP_LEGEND_FONT_SIZE, - title_fontsize=FigMisc.ZBD_COMP_LEGEND_TITLE_SIZE) - elif np.any(nan_mask) and Params.LEGEND: - # Show legend for invalid data points if no composition lines are shown - ax.legend(fontsize=FigMisc.ZBD_COMP_LEGEND_FONT_SIZE) - - plt.tight_layout() - fig.savefig(Params.FigureFiles.exploreZbD, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) - log.debug(f'Combined ZbD plot saved to file: {Params.FigureFiles.exploreZbD}') - plt.close() - - return - - -def PlotExploreOgramLoveComparison(ExplorationList, Params): - """ Plot a scatter showing the evaluated tidal love number k2 versus delta (1+k2-h2), - for comparison against canonical k2/delta exploration plots. - """ - - for Exploration in (ex for ex in ExplorationList if not ex.NO_H2O): - Exploration.xName = 'delta_love_number_relation' - Exploration.yName = 'k_love_number' - Exploration.zName = 'oceanComp' - FigLbl.SetExploration(Exploration.bodyname, Exploration.xName, Exploration.yName, Exploration.zName) - if not FigMisc.TEX_INSTALLED: - FigLbl.StripLatex() + thisColor = None + if FigMisc.SCALE_HYDRO_LW and wMinMax_ppt[Planet.Ocean.comp][0] != wMinMax_ppt[Planet.Ocean.comp][1]: + thisLW = Style.GetLW(Planet.Ocean.wOcean_ppt, wMinMax_ppt[Planet.Ocean.comp]) + else: + thisLW = Style.LW_std - fig = plt.figure(figsize=FigSize.explore) - grid = GridSpec(1, 1) - ax = fig.add_subplot(grid[0, 0]) - if Style.GRIDS: - ax.grid() - ax.set_axisbelow(True) + # Plot temperature profile vs. depth in hydrosphere + therm = axTz.plot(Planet.T_K[:Planet.Steps.nHydro] - FigLbl.Tsub, + Planet.z_m[:Planet.Steps.nHydro]/1e3, + color=thisColor, linewidth=thisLW, + linestyle=Style.LS[Planet.Ocean.comp], label = legLbl) + # Make a dot at the end of the thermal profile, if there's an ocean + if Planet.Steps.nHydro > 0: + Tdots_K = np.max(Planet.T_K[:Planet.Steps.nHydro] - FigLbl.Tsub) + axTz.scatter(Tdots_K, + np.max(Planet.z_m[:Planet.Steps.nHydro]/1e3), + color=therm[-1].get_color(), edgecolors=therm[-1].get_color(), + marker=Style.MS_hydro, s=Style.MW_hydro**2*thisLW) - if Params.TITLES: - fig.suptitle(FigLbl.explorationLoveComparisonTitle) - ax.set_xlabel(FigLbl.xLabelExplore) - ax.set_ylabel(FigLbl.yLabelExplore) - # Override standard settings for this type of plot - ax.set_xscale('linear') - ax.set_yscale('linear') - - x = np.reshape(Exploration.__getattribute__(Exploration.xName) * FigLbl.xMultExplore, -1) - y = np.reshape(Exploration.__getattribute__(Exploration.yName) * FigLbl.yMultExplore, -1) - z = np.reshape(Exploration.__getattribute__(Exploration.zName), -1) - # Only keep data points for which a valid model was determined - VALID = np.logical_not(np.logical_or(np.isnan(x), np.isnan(y))) - x, y, z = x[VALID], y[VALID], z[VALID] - - # Draw composition lines if enabled - if FigMisc.DRAW_COMPOSITION_LINE: - # Group by unique z values and plot connecting lines - unique_z = np.unique(z) - for z_val in unique_z: - TminMax_K = {} - Tall_K = np.reshape(Exploration.__getattribute__('Tb_K'), -1) - TminMax_K[z_val] = [np.nanmin(Tall_K), np.nanmax(Tall_K)] - # Set style options - if FigMisc.MANUAL_HYDRO_COLORS: - Color.Tbounds_K = TminMax_K[z_val] - thisColor = Color.cmap[z_val](Color.GetNormT(np.nanmax(Tall_K))) - else: - thisColor = None - indices = np.where(z == z_val)[0] - x_line, y_line = x[indices], y[indices] - # Sort points by x for connected lines - sorted_idx = np.argsort(x_line) - x_line = x_line[sorted_idx] - y_line = y_line[sorted_idx] - if 'CustomSolution' in z_val: - z_label = z_val.split('=')[0].replace('CustomSolution', '') - else: - z_label = z_val - # Plot line for each z group - ax.plot(x_line, y_line, color=thisColor, label=f'{z_label}', - linewidth=FigMisc.LOVE_COMP_LINE_WIDTH, alpha=FigMisc.LOVE_COMP_LINE_ALPHA, zorder=2) - # Add error bars if enabled - if FigMisc.SHOW_ERROR_BARS: - ax.errorbar(x_line, y_line, yerr=FigMisc.ERROR_BAR_MAGNITUDE, fmt='none', color=thisColor, capsize=3) - - # Handle ice thickness coloring or regular scatter - if FigMisc.SHOW_ICE_THICKNESS_DOTS: - # Get ice shell thickness and normalize for coloring - ice_thickness = np.reshape(Exploration.__getattribute__('zb_approximate_km'), -1) - ice_thickness = ice_thickness[VALID] # Filter invalid data like x and y - if np.size(ice_thickness) > 0: - # Set bounds for normalization (min and max of ice thickness) - Tbound_lower = np.min(ice_thickness) - Tbound_upper = np.max(ice_thickness) - # Get normalized values using GetNormT - norm_thickness = Color.GetNormT(ice_thickness, Tbound_lower, Tbound_upper) - else: - norm_thickness = None - - # Apply colormap using normalized thickness - pts = ax.scatter(x, y, c=norm_thickness, - cmap=FigMisc.LOVE_ICE_THICKNESS_CMAP, marker=Style.MS_Induction, - s=Style.MW_Induction**2, edgecolors=FigMisc.LOVE_DOT_EDGE_COLOR, - linewidths=FigMisc.LOVE_DOT_EDGE_WIDTH, zorder=3) - - # Create legend showing ice thickness values instead of colorbar - if np.size(ice_thickness) > 0: - # Round thickness values to avoid floating-point duplicates - rounded_thicknesses = np.round(ice_thickness) - unique_thicknesses = np.unique(rounded_thicknesses) - - if len(unique_thicknesses) > FigMisc.LOVE_MAX_LEGEND_ENTRIES: # Limit number of legend entries - # Select representative values - indices = np.linspace(0, len(unique_thicknesses)-1, FigMisc.LOVE_MAX_LEGEND_ENTRIES, dtype=int) - selected_thicknesses = unique_thicknesses[indices] - else: - selected_thicknesses = unique_thicknesses - - # Create legend elements - legend_elements = [] - for thickness in selected_thicknesses: - norm_val = Color.GetNormT(thickness, Tbound_lower, Tbound_upper) - color = getattr(plt.cm, FigMisc.LOVE_ICE_THICKNESS_CMAP)(norm_val) - legend_elements.append(plt.Line2D([0], [0], marker='o', color='w', - markerfacecolor=color, markeredgecolor=FigMisc.LOVE_DOT_EDGE_COLOR, - markersize=8, label=f'{thickness:.0f}')) - - # Add legend for ice thickness - if Params.LEGEND: - thickness_legend = ax.legend(handles=legend_elements, title="Ice Shell Thickness (km)", - loc='upper right', bbox_to_anchor=(1.0, 1.0), - fontsize=FigMisc.LOVE_ICE_LEGEND_FONT_SIZE, title_fontsize=FigMisc.LOVE_ICE_LEGEND_TITLE_SIZE) - ax.add_artist(thickness_legend) # Keep this legend when adding composition legend - else: - # Standard scatter plot without ice thickness coloring - # Get ice shell thickness for backward compatibility - ice_thickness = np.reshape(Exploration.__getattribute__('zb_approximate_km'), -1) - ice_thickness = ice_thickness[VALID] - if np.size(ice_thickness) > 0: - # Set bounds for normalization (min and max of ice thickness) - Tbound_lower = np.min(ice_thickness) - Tbound_upper = np.max(ice_thickness) - # Get normalized values using GetNormT - norm_thickness = Color.GetNormT(ice_thickness, Tbound_lower, Tbound_upper) - else: - norm_thickness = None - - # Apply colormap using normalized thickness (maintaining backward compatibility) - pts = ax.scatter(x, y, c=norm_thickness, - cmap=FigMisc.LOVE_ICE_THICKNESS_CMAP, marker=Style.MS_Induction, - s=Style.MW_Induction**2, edgecolors=FigMisc.LOVE_DOT_EDGE_COLOR, - linewidths=FigMisc.LOVE_DOT_EDGE_WIDTH, zorder=3) - - # Set axis limits - if np.size(x) > 0: - ax.set_xlim([0, np.max(x) + 0.01]) - if np.size(y) > 0: - # Account for error bars when setting y limits - if FigMisc.SHOW_ERROR_BARS: - error_magnitude = FigMisc.ERROR_BAR_MAGNITUDE - y_min_with_error = np.min(y) - error_magnitude - y_max_with_error = np.max(y) + error_magnitude - ax.set_ylim([np.floor(y_min_with_error*100)/100, np.ceil(y_max_with_error*100)/100]) - else: - ax.set_ylim([np.floor(np.min(y)*100)/100, np.ceil(np.max(y)*100)/100]) - - # Add legend for composition lines if enabled - if FigMisc.DRAW_COMPOSITION_LINE and Params.LEGEND: - if FigMisc.SHOW_ICE_THICKNESS_DOTS: - # Position composition legend to avoid overlap with ice thickness legend - ax.legend(title="Ocean Composition", fontsize=FigMisc.LOVE_COMP_LEGEND_FONT_SIZE, - title_fontsize=FigMisc.LOVE_COMP_LEGEND_TITLE_SIZE, - loc='upper left', bbox_to_anchor=(0.0, 1.0)) - else: - ax.legend(title="Ocean Composition", fontsize=FigMisc.LOVE_COMP_LEGEND_FONT_SIZE, - title_fontsize=FigMisc.LOVE_COMP_LEGEND_TITLE_SIZE) + # Plot pressure profile vs. depth in hydrosphere + press = axPz.plot(Planet.P_MPa[:Planet.Steps.nHydro] * FigLbl.PmultHydro, + Planet.z_m[:Planet.Steps.nHydro]/1e3, + color=thisColor, linewidth=thisLW, + linestyle=Style.LS[Planet.Ocean.comp]) + # Make a dot at the end of the pressure profile, if there's an ocean + if Planet.Steps.nHydro > 0: + axPz.scatter(np.max(Planet.P_MPa[:Planet.Steps.nHydro] * FigLbl.PmultHydro), + np.max(Planet.z_m[:Planet.Steps.nHydro]/1e3), + color=press[-1].get_color(), edgecolors=press[-1].get_color(), + marker=Style.MS_hydro, s=Style.MW_hydro**2*thisLW) - plt.tight_layout() - fig.savefig(Params.FigureFiles.exploreLoveComparison, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) - log.debug(f'Plot saved to file: {Params.FigureFiles.exploreLoveComparison}') - plt.close() + # Plot density vs. depth in hydrosphere + density = axrho.plot(Planet.rho_kgm3[:Planet.Steps.nHydro], Planet.z_m[:Planet.Steps.nHydro] / 1e3, + color=thisColor, linewidth=thisLW, + linestyle=Style.LS[Planet.Ocean.comp]) + # Make a dot at the end of the density profile, if there's an ocean + if Planet.Steps.nHydro > 0: + rhodots_kgm3 = np.max(Planet.rho_kgm3[:Planet.Steps.nHydro]) + axrho.scatter(rhodots_kgm3, + np.max(Planet.z_m[:Planet.Steps.nHydro]/1e3), + color=density[-1].get_color(), edgecolors=density[-1].get_color(), + marker=Style.MS_hydro, s=Style.MW_hydro**2*thisLW) + + # Plot thermal expansion coefficient vs. depth in hydrosphere + alpha = axalpha.plot(Planet.alpha_pK[:Planet.Steps.nHydro], Planet.z_m[:Planet.Steps.nHydro] / 1e3, + color=thisColor, linewidth=thisLW, + linestyle=Style.LS[Planet.Ocean.comp]) + # Make a dot at the end of the alpha profile, if there's an ocean + if Planet.Steps.nHydro > 0: + alphadots_pK = Planet.alpha_pK[:Planet.Steps.nHydro][-1] + axalpha.scatter(alphadots_pK, + np.max(Planet.z_m[:Planet.Steps.nHydro]/1e3), + color=alpha[-1].get_color(), edgecolors=alpha[-1].get_color(), + marker=Style.MS_hydro, s=Style.MW_hydro**2*thisLW) + + # Plot specific heat capacity vs. depth in hydrosphere + cp = axCp.plot(Planet.Cp_JkgK[:Planet.Steps.nHydro], Planet.z_m[:Planet.Steps.nHydro] / 1e3, + color=thisColor, linewidth=thisLW, + linestyle=Style.LS[Planet.Ocean.comp]) + # Make a dot at the end of the Cp profile, if there's an ocean + if Planet.Steps.nHydro > 0: + cpdots_JkgK = Planet.Cp_JkgK[:Planet.Steps.nHydro][-1] + axCp.scatter(cpdots_JkgK, + np.max(Planet.z_m[:Planet.Steps.nHydro]/1e3), + color=cp[-1].get_color(), edgecolors=cp[-1].get_color(), + marker=Style.MS_hydro, s=Style.MW_hydro**2*thisLW) - # Plot combination - if Params.COMPARE and np.size(ExplorationList) > 1: - # Filter out explorations with no H2O - ValidExplorations = [ex for ex in ExplorationList if not ex.NO_H2O] - - if len(ValidExplorations) > 1: - # Set up exploration parameters for the first valid exploration - Exploration = ValidExplorations[0] - Exploration.xName = 'delta_love_number_relation' - Exploration.yName = 'k_love_number' - Exploration.zName = 'oceanComp' - FigLbl.SetExploration(Exploration.bodyname, Exploration.xName, Exploration.yName, Exploration.zName) - if not FigMisc.TEX_INSTALLED: - FigLbl.StripLatex() + if FigMisc.FORCE_0_EDGES: + [ax.set_ylim(top=0) for ax in axes] - fig = plt.figure(figsize=FigSize.explore) - grid = GridSpec(1, 1) - ax = fig.add_subplot(grid[0, 0]) - if Style.GRIDS: - ax.grid() - ax.set_axisbelow(True) - - if Params.TITLES: - fig.suptitle(FigLbl.explorationLoveComparisonTitle) - ax.set_xlabel(FigLbl.xLabelExplore) - ax.set_ylabel(FigLbl.yLabelExplore) - # Override standard settings for this type of plot - ax.set_xscale('linear') - ax.set_yscale('linear') - - # Collect data from all explorations - all_x = [] - all_y = [] - all_z = [] - all_ice_thickness = [] - exploration_labels = [] - - for i, Exploration in enumerate(ValidExplorations): - x = np.reshape(Exploration.__getattribute__(Exploration.xName) * FigLbl.xMultExplore, -1) - y = np.reshape(Exploration.__getattribute__(Exploration.yName) * FigLbl.yMultExplore, -1) - z = np.reshape(Exploration.__getattribute__(Exploration.zName), -1) - # Only keep data points for which a valid model was determined - VALID = np.logical_not(np.logical_or(np.isnan(x), np.isnan(y))) - x, y, z = x[VALID], y[VALID], z[VALID] - - # Get ice thickness data - ice_thickness = np.reshape(Exploration.__getattribute__('zb_approximate_km'), -1) - ice_thickness = ice_thickness[VALID] - - # Add exploration identifier - exploration_id = np.full(len(x), i) - - all_x.extend(x) - all_y.extend(y) - all_z.extend(z) - all_ice_thickness.extend(ice_thickness) - exploration_labels.extend(exploration_id) - - # Convert to numpy arrays - all_x = np.array(all_x) - all_y = np.array(all_y) - all_z = np.array(all_z) - all_ice_thickness = np.array(all_ice_thickness) - exploration_labels = np.array(exploration_labels) - - # Draw composition lines if enabled - if FigMisc.DRAW_COMPOSITION_LINE: - # Group by unique z values and plot connecting lines - unique_z = np.unique(all_z) - plotted_labels = set() # Keep track of which labels have been plotted - - for z_val in unique_z: - z_indices = np.where(all_z == z_val)[0] - - # Collect all points for this z value across all explorations - x_line, y_line = all_x[z_indices], all_y[z_indices] - - # Sort points by x for connected lines - sorted_idx = np.argsort(x_line) - x_line = x_line[sorted_idx] - y_line = y_line[sorted_idx] - - # Set style options (use first exploration's temperature bounds) - TminMax_K = {} - Tall_K = np.reshape(ValidExplorations[0].__getattribute__('Tb_K'), -1) - TminMax_K[z_val] = [np.nanmin(Tall_K), np.nanmax(Tall_K)] - - if FigMisc.MANUAL_HYDRO_COLORS: - Color.Tbounds_K = TminMax_K[z_val] - thisColor = Color.cmap[z_val](Color.GetNormT(np.nanmax(Tall_K))) - else: - thisColor = None - - if 'CustomSolution' in z_val: - z_label = z_val.split('=')[0].replace('CustomSolution', '') - else: - z_label = z_val - - # Only add label if this composition hasn't been plotted yet - line_label = z_label if z_label not in plotted_labels else None - if line_label: - plotted_labels.add(z_label) - - # Plot line for each z group combining all explorations - ax.plot(x_line, y_line, color=thisColor, label=line_label, - linewidth=FigMisc.LOVE_COMP_LINE_WIDTH, alpha=FigMisc.LOVE_COMP_LINE_ALPHA, zorder=2) - - # Add error bars if enabled - if FigMisc.SHOW_ERROR_BARS: - ax.errorbar(x_line, y_line, yerr=FigMisc.ERROR_BAR_MAGNITUDE, fmt='none', color=thisColor, capsize=3) - - # Handle ice thickness coloring or regular scatter - if FigMisc.SHOW_ICE_THICKNESS_DOTS and len(all_ice_thickness) > 0: - # Set bounds for normalization (min and max of ice thickness across all explorations) - Tbound_lower = np.min(all_ice_thickness) - Tbound_upper = np.max(all_ice_thickness) - - # Get normalized values using GetNormT - norm_thickness = Color.GetNormT(all_ice_thickness, Tbound_lower, Tbound_upper) - else: - norm_thickness = None + # Limit Tmin so the relevant plot can better show what's going on in the ocean + Tmax = np.max([np.max(Planet.T_K[:Planet.Steps.nHydro] - FigLbl.Tsub) for Planet in PlanetList if not Planet.Do.NO_H2O]) + Tlims = [FigMisc.TminHydro, FigMisc.TminHydro + 1.05*(Tmax - FigMisc.TminHydro)] + axTz.set_xlim([np.min(Tlims), np.max(Tlims)]) - # Plot scatter points using the same marker for all explorations - ax.scatter(all_x, all_y, c=norm_thickness, - cmap=FigMisc.LOVE_ICE_THICKNESS_CMAP, marker=Style.MS_Induction, - s=Style.MW_Induction**2, edgecolors=FigMisc.LOVE_DOT_EDGE_COLOR, - linewidths=FigMisc.LOVE_DOT_EDGE_WIDTH, zorder=3) - - # Create legend showing ice thickness values instead of colorbar - if FigMisc.SHOW_ICE_THICKNESS_DOTS and len(all_ice_thickness) > 0 and Params.LEGEND: - # Round thickness values to avoid floating-point duplicates - rounded_thicknesses = np.round(all_ice_thickness) - unique_thicknesses = np.unique(rounded_thicknesses) - - if len(unique_thicknesses) > FigMisc.LOVE_MAX_LEGEND_ENTRIES: # Limit number of legend entries - # Select representative values - indices = np.linspace(0, len(unique_thicknesses)-1, FigMisc.LOVE_MAX_LEGEND_ENTRIES, dtype=int) - selected_thicknesses = unique_thicknesses[indices] - else: - selected_thicknesses = unique_thicknesses - - # Create legend elements - legend_elements = [] - for thickness in selected_thicknesses: - norm_val = Color.GetNormT(thickness, Tbound_lower, Tbound_upper) - color = getattr(plt.cm, FigMisc.LOVE_ICE_THICKNESS_CMAP)(norm_val) - legend_elements.append(plt.Line2D([0], [0], marker='o', color='w', - markerfacecolor=color, markeredgecolor=FigMisc.LOVE_DOT_EDGE_COLOR, - markersize=8, label=f'{thickness:.0f}')) - - # Add legend for ice thickness - thickness_legend = ax.legend(handles=legend_elements, title="Ice Shell Thickness (km)", - loc='upper right', bbox_to_anchor=(1.0, 1.0), - fontsize=FigMisc.LOVE_ICE_LEGEND_FONT_SIZE, title_fontsize=FigMisc.LOVE_ICE_LEGEND_TITLE_SIZE) - ax.add_artist(thickness_legend) # Keep this legend when adding composition legend - - # Set axis limits - if np.size(all_x) > 0: - ax.set_xlim([0, np.max(all_x) + 0.01]) - if np.size(all_y) > 0: - # Account for error bars when setting y limits - if FigMisc.SHOW_ERROR_BARS: - error_magnitude = FigMisc.ERROR_BAR_MAGNITUDE - y_min_with_error = np.min(all_y) - error_magnitude - y_max_with_error = np.max(all_y) + error_magnitude - ax.set_ylim([np.floor(y_min_with_error*100)/100, np.ceil(y_max_with_error*100)/100]) - else: - ax.set_ylim([np.floor(np.min(all_y)*100)/100, np.ceil(np.max(all_y)*100)/100]) - - # Add legend for composition lines if enabled - if FigMisc.DRAW_COMPOSITION_LINE and Params.LEGEND: - if FigMisc.SHOW_ICE_THICKNESS_DOTS: - # Position composition legend to avoid overlap with ice thickness legend - ax.legend(title="Ocean Composition", fontsize=FigMisc.LOVE_COMP_LEGEND_FONT_SIZE, - title_fontsize=FigMisc.LOVE_COMP_LEGEND_TITLE_SIZE, - loc='upper left', bbox_to_anchor=(0.0, 1.0)) - else: - ax.legend(title="Ocean Composition", fontsize=FigMisc.LOVE_COMP_LEGEND_FONT_SIZE, - title_fontsize=FigMisc.LOVE_COMP_LEGEND_TITLE_SIZE) + if Params.LEGEND: + handles, lbls = axTz.get_legend_handles_labels() + axTz.legend(handles, lbls, loc='upper right') - plt.tight_layout() - fig.savefig(Params.FigureFiles.exploreLoveComparison, format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta) - log.debug(f'Combined Love comparison plot saved to file: {Params.FigureFiles.exploreLoveComparison}') - plt.close() + plt.tight_layout() + fig.savefig(Params.FigureFiles.vhydroThermo, + format=FigMisc.figFormat, dpi=FigMisc.dpi, metadata=FigLbl.meta, transparent=FigMisc.TRANSPARENT) + log.debug(f'Hydrosphere thermodynamics plot saved to file: {Params.FigureFiles.vhydroThermo}') + plt.close() - return + return \ No newline at end of file diff --git a/PlanetProfile/Plotting/defaultConfigPlots.py b/PlanetProfile/Plotting/defaultConfigPlots.py index 5b0761b2..2bba268e 100644 --- a/PlanetProfile/Plotting/defaultConfigPlots.py +++ b/PlanetProfile/Plotting/defaultConfigPlots.py @@ -4,7 +4,7 @@ from PlanetProfile.Utilities.defineStructs import ColorStruct, StyleStruct, \ FigLblStruct, FigSizeStruct, FigMiscStruct -configPlotsVersion = 27 # Integer number for config file version. Increment when new settings are added to the +configPlotsVersion = 29 # Integer number for config file version. Increment when new settings are added to the # default config file. def plotAssign(): @@ -202,7 +202,8 @@ def plotAssign(): Style.LW_wedgeMajor = 0.375 # Linewidth in pt for major layer boundaries in wedge diagrams Style.TS_ticks = 12 # Text size in pt for tick marks on radius scale Style.TS_desc = 14 # Text size in pt for model description and label - Style.TS_super = 16 # Text size in pt for overall ("suptitle") label with multiple wedges + Style.TS_super = 26 # Text size in pt for overall ("suptitle") label with multiple wedges + Style.TS_axis = 20 # Text size in pt for axis labels Style.LS_markRadii = '--' # Linestyle for radii mark line when toggled on Style.LW_markRadii = 0.375 # Linewidth for radii mark line when toggled on @@ -278,6 +279,7 @@ def plotAssign(): FigSize.vperm = (6, 6) FigSize.vseis = (6, 6) FigSize.vhydro = (12, 9) + FigSize.vhydroThermo = (12, 9) FigSize.vgrav = (6, 5) FigSize.vmant = (6, 6) FigSize.vcore = (6, 6) @@ -285,6 +287,7 @@ def plotAssign(): FigSize.vpvt = (12, 6) FigSize.vwedg = (4.5, 4.5) FigSize.vphase = (5, 6) + FigSize.vmeltingCurves = (8, 6) FigSize.vhydroSpecies = (7, 5) FigSize.explore = (6, 4) FigSize.phaseSpaceSolo = (6, 4) @@ -306,6 +309,7 @@ def plotAssign(): FigSize.AlfvenWing = (6, 6) FigSize.asym = (8, 5) FigSize.apsidal = (6, 6) + FigSize.imaginaryRealSoloCombo = (6, 6) """ Miscellaneous figure options """ @@ -317,7 +321,9 @@ def plotAssign(): FigMisc.defaultFontCode = 'stix' # Code name for default font needed in some function calls FigMisc.backupFont = 'Times New Roman' # Backup font that looks similar to STIX that most users are likely to have FigMisc.FORCE_0_EDGES = True # Sets the edge of plots with 0 radius, depth, pressure, etc. to be the edge of the axes, instead of including white space which is the default. - + FigMisc.TRANSPARENT = True # Whether to make the background transparent for all plots #TODO Implement for all plots + + # Hydrosphere plots FigMisc.LOG_SIG = False # Whether to print conductivity plot on a log scale FigMisc.COMMON_ZMAX_SIG = False # Whether to force conductivity plot to have the same maximum depth as other hydrosphere plots, or to let the bottom axis set automatically to zoom in on the ocean. Only has an effect for undersea HP ices. @@ -325,7 +331,6 @@ def plotAssign(): FigMisc.SCALE_HYDRO_LW = True # Whether to adjust thickness of lines on hydrosphere plot according to relative salinity FigMisc.MANUAL_HYDRO_COLORS = True # Whether to set color of lines in hydrosphere according to melting temperature FigMisc.RELATIVE_Tb_K = True # Whether to set colormap of lines based on relative comparison (or fixed settings in ColorStruct) - FigMisc.PLOT_DENSITY_VERSUS_DEPTH = False # Whether to plot density versus depth instead of pressure FigMisc.lowSigCutoff_Sm = 1e-3 # Cutoff conductivity below which profiles will be excluded. Setting to None includes all profiles FigMisc.PminHydro_MPa = None # Minimum pressure to use for hydrosphere and phase diagram PT plots in MPa. Set to None to use min of geotherm. FigMisc.TminHydro = 250 # Minimum temperature to display on hydrosphere plots @@ -335,7 +340,19 @@ def plotAssign(): FigLbl.TS_hydroLabels = 18 # Font size for hydrosphere phase labels in pt FigLbl.hydroTitleSize = 20 # Font size for hydrosphere title in pt FigMisc.SHOW_GEOTHERM = True # Whether to plot geotherm on PTplots + FigMisc.PLOT_DENSITY_VERSUS_DEPTH = False # Whether to plot density versus depth instead of pressure + # Melting curve plots + FigMisc.SHOW_GEOTHERM = False # Whether to show geotherm curves on melting curve plots + FigMisc.MARK_MODEL_POINTS = True # Whether to mark the model melting points (Tb_K, Pb_MPa) on melting curves + FigMisc.nTmeltingCurve = 500 # Number of temperature points to use for melting curve calculation + FigMisc.nPmeltingCurve = 500 # Number of pressure points to use for melting curve calculation + FigMisc.MELTING_CURVE_LINE_WIDTH = 2.0 # Line width for melting curves + FigMisc.MODEL_POINT_SIZE = 50 # Marker size for model melting points + FigMisc.LS_SOLID_MELTING_CURVES = True # Whether to use solid linestyle for melting curves + FigMisc.TmaxMeltingCurve_K = 280 # When set, maximum temperature to use for melting curves in K. Set to None to use max of geotherm. + FigMisc.TminMeltingCurve_K = 250 # When set, minimum temperature to use for melting curves in K. Set to None to use min of geotherm. + # Wedge diagrams FigMisc.IONOSPHERE_IN_WEDGE = False # Whether to include specified ionosphere in wedge diagram FigMisc.WEDGE_ICE_TICKS = False # Whether to print ticks for ice shell, which usually overlap with the body outer radius @@ -355,14 +372,17 @@ def plotAssign(): FigMisc.nPhydro = 200 # Number of pressure points to evaluate/plot for PT property plots FigMisc.PminHydro_MPa = 0.1 # Minimum pressure to use for hydrosphere and phase diagram PT plots in MPa. Set to None to use min of geotherm. FigMisc.TminHydro_K = 240 # Minimum temperature to use for hydrosphere and phase diagram PT plots in K. Set to None to use min of geotherm. - FigMisc.TmaxHydro_K = 300 + FigMisc.TmaxHydro_K = 350 FigMisc.PmaxHydro_MPa = 200 FigLbl.hydroPhaseSize = 14 # Font size of label for phase in phase diagram FigMisc.propsToPlot = ['rho', 'Cp', 'alpha', 'VP', 'KS', 'sig', 'VS', 'GS'] # Properties to plot in PvT or IsoTherm plots. Options are - 'rho', 'Cp', 'alpha', 'VP', 'KS', 'sig', 'VS', 'GS' - FigMisc.TtoPlot_K = [273, 278, 283, 288, 298, 305] # Temperatures to plot in isothermal configuration + + # Hydrosphere isobaric plots + FigMisc.TtoPlot_K = [273, 278, 283, 288, 298, 305] # Temperatures to plot in isobaric configuration #Hydrosphere Species diagrams - FigMisc.minThreshold = 1e-14 # Minimum mol of species needed to be considered to plot on hydrosphere species diagram + FigMisc.minAqueousThreshold = 1e-14 # Minimum mol of species needed to be considered to plot on hydrosphere species diagram + FigMisc.minVolSolidThreshold_cm3 = 1e-12 # Minimum volume of solid needed to be considered to plot on hydrosphere species diagram FigMisc.excludeSpeciesFromHydrospherePlot = ['H2O(aq)', 'H+', 'OH-'] # Species to exclude from the hydrosphere plots FigMisc.aqueousSpeciesLabels = ['+', '-', '(aq)'] # Aqueous species to include in the aqueous-specific hydrosphere plot FigMisc.gasSpeciesLabels = ['(g)'] # Solid species to include in the aqueous-specific hydrosphere plot @@ -424,6 +444,7 @@ def plotAssign(): FigMisc.DARKEN_SALINITIES = False # Whether to match hues to the colorbar, but darken points based on salinity, or to just use the colorbar colors. FigMisc.NORMALIZED_SALINITIES = False # Whether to normalize salinities to absolute concentrations relative to the saturation limit for each salt FigMisc.NORMALIZED_TEMPERATURES = False # Whether to normalize ocean mean temperatures to specified maxima and minima for the colormap + FigMisc.EDGE_COLOR_K_IN_COMPLEX_PLOTS = False # Whether to color the edge of the scatter dots in complex plots by the k2 Love number # Inductograms FigMisc.MARK_INDUCT_BOUNDS = True # Whether to draw a border around the models on sigma/D plot when combined FigMisc.PLOT_V2021 = False # Whether to mark the selected ocean/conductivity combos used in Vance et al. 2021 @@ -433,17 +454,31 @@ def plotAssign(): FigMisc.MARK_BEXC_MAX = True # Whether to annotate excitation spectrum plots with label for highest peak FigLbl.peakLblSize = 14 # Font size in pt for highest-peak annotation FigMisc.Tmin_hr = None # Cutoff period to limit range of Fourier space plots + """Exploreogram plot settings""" + FigMisc.EXPLOREOGRAM_SMOOTHING = True # Whether to smooth the exploreogram plots by interpolating to a finer grid + FigMisc.EXPLOREOGRAM_SMOOTHING_FACTOR = 10 # Factor to use for smoothing the exploreogram plots by interpolating to a finer grid when EXPLOREOGRAM_SMOOTHING is True + FigMisc.EXPLOREOGRAM_COMPARISON_DIFFERENCE_TYPE = 'absolute' # Whether to calculate the absolute or relative difference between exploration results + FigLbl.overrideSubplotExplorationTitle = None # Overrides exploreogram subplot title (i.e. when zName is a list to plot) with user-specified title string + FigLbl.overrideExplorationTitle = None # Overrides exploreogram figure title with user-specified title string # Exploreogram D/sigma settings FigMisc.DRAW_COMPOSITION_LINE = True # Whether to draw a line for each composition in the exploreogram D/sigma plot FigMisc.SHOW_ICE_THICKNESS_DOTS = True # Whether to show ice thickness dots instead of colorbar in D/sigma plots FigMisc.DSIGMA_YLIMS = [1e-2, 20] # Y-axis limits for D/sigma plots [min, max] in S/m FigMisc.DSIGMA_ICE_THICKNESS_CMAP = 'Greys' # Colormap to use for ice thickness dots in D/sigma plots FigMisc.DSIGMA_DOT_EDGE_COLOR = 'black' # Edge color for scatter dots in D/sigma plots - FigMisc.DSIGMA_DOT_EDGE_WIDTH = 0.5 # Edge line width for scatter dots in D/sigma plots + FigMisc.DSIGMA_DOT_EDGE_WIDTH = 5 # Edge line width for scatter dots in D/sigma plots FigMisc.DSIGMA_COMP_LINE_WIDTH = 2 # Line width for composition lines in D/sigma plots FigMisc.DSIGMA_COMP_LINE_ALPHA = 0.7 # Alpha transparency for composition lines in D/sigma plots FigMisc.DSIGMA_ICE_LEGEND_FONT_SIZE = 8 # Font size for ice thickness legend entries FigMisc.DSIGMA_ICE_LEGEND_TITLE_SIZE = 10 # Font size for ice thickness legend title + + # Monte Carlo scatter plot ice thickness highlighting + FigMisc.HIGHLIGHT_ICE_THICKNESSES = True # Whether to highlight specific ice shell thicknesses in scatter plots + FigMisc.DO_SCATTER_INSET = True + FigMisc.ICE_THICKNESSES_TO_SHOW = [30] # List of ice shell thicknesses in km to highlight + FigMisc.ICE_THICKNESS_TOLERANCE = 1 # Tolerance in km for matching ice thicknesses + FigMisc.SCATTER_DOT_SIZE = 10 # Size of scatter dots + FigMisc.DIMMED_ALPHA = 0.0 # Alpha value for non-highlighted points when ice thickness highlighting is enabled FigMisc.DSIGMA_COMP_LEGEND_FONT_SIZE = 10 # Font size for composition legend entries FigMisc.DSIGMA_COMP_LEGEND_TITLE_SIZE = 12 # Font size for composition legend title FigMisc.DSIGMA_MAX_LEGEND_ENTRIES = 10 # Maximum number of entries to show in ice thickness legend @@ -458,12 +493,13 @@ def plotAssign(): FigMisc.LOVE_COMP_LEGEND_FONT_SIZE = 10 # Font size for composition legend entries in Love plots FigMisc.LOVE_COMP_LEGEND_TITLE_SIZE = 12 # Font size for composition legend title in Love plots FigMisc.LOVE_MAX_LEGEND_ENTRIES = 10 # Maximum number of entries to show in ice thickness legend for Love plots + FigMisc.SHOW_CONVECTION_WITH_SHAPE = True # Whether to use different marker shapes for convection vs non-convection in Love comparison plots # Exploreogram ZbD (ice shell vs ocean thickness) settings FigMisc.ZBD_DOT_EDGE_COLOR = 'black' # Edge color for scatter dots in ZbD plots FigMisc.ZBD_DOT_EDGE_WIDTH = 0.5 # Edge line width for scatter dots in ZbD plots FigMisc.ZBD_COMP_LINE_WIDTH = 2 # Line width for composition lines in ZbD plots FigMisc.ZBD_COMP_LINE_ALPHA = 0.7 # Alpha transparency for composition lines in ZbD plots - FigMisc.ZBD_COMP_LEGEND_FONT_SIZE = 10 # Font size for composition legend entries in ZbD plots + FigMisc.ZBD_COMP_LEGEND_FONT_SIZE = 8 # Font size for composition legend entries in ZbD plots FigMisc.ZBD_COMP_LEGEND_TITLE_SIZE = 12 # Font size for composition legend title in ZbD plots FigMisc.ZBD_COLORMAP = 'Greys' # Colormap to use for z-variable in ZbD plots FigMisc.ZBD_NAN_COLOR = 'red' # Color to use for NaN points in ZbD plots @@ -498,8 +534,10 @@ def plotAssign(): FigMisc.HF_HLINES = True # Whether to print horizontal lines at head and foot of latex tables FigMisc.COMP_ROW = True # Whether to force composition into a row instead of printing a separate summary table for each ocean comp FigMisc.BODY_NAME_ROW = True # Whether to print a row with body name in bold in summary table + # Custom solution settings FigMisc.CustomSolutionSingleCmap = True # Whether to use a single colormap for all custom solution plots - where each custom solution is a different color based on the colormap. + # Contour labels FigMisc.cLabelSize = 10 # Font size in pt for contour labels FigMisc.cLabelPad = 5 # Padding in pt to set beside contour labels diff --git a/PlanetProfile/Test/.gitignore b/PlanetProfile/Test/.gitignore index 577313b2..947af30b 100644 --- a/PlanetProfile/Test/.gitignore +++ b/PlanetProfile/Test/.gitignore @@ -1,6 +1,6 @@ *~ .DS_Store *.asv -*.mat +*.pkl *.dat *.txt \ No newline at end of file diff --git a/PlanetProfile/Test/PPTest16.py b/PlanetProfile/Test/PPTest16.py index a68bfc8a..59f4410d 100644 --- a/PlanetProfile/Test/PPTest16.py +++ b/PlanetProfile/Test/PPTest16.py @@ -35,6 +35,8 @@ Planet.Sil.Htidal_Wm3 = 1e-18 # Rock porosity Planet.Do.POROUS_ROCK = True +Planet.Sil.wPore_ppt = Planet.Ocean.wOcean_ppt +Planet.Sil.poreComp = Planet.Ocean.comp Planet.Sil.phiRockMax_frac = 0.4 # Mantle equation of state model Planet.Sil.mantleEOS = 'CM_hydrous_differentiated_Ganymede_Core080Fe020S_excluding_fluid_properties.tab' diff --git a/PlanetProfile/Test/PPTest21.py b/PlanetProfile/Test/PPTest21.py index 8a70751c..2c91a2a4 100644 --- a/PlanetProfile/Test/PPTest21.py +++ b/PlanetProfile/Test/PPTest21.py @@ -20,7 +20,6 @@ Planet.Bulk.Cuncertainty = 0.01 # No uncertainty is reported by Durante et al. Planet.Do.NONHYDROSTATIC = False Planet.Bulk.Tb_K = Constants.triplePointT_K - 5 # Set the Tb_K to be slightly below the triple point to ensure we are in the ice Ih to high pressure ice phase transition -Planet.Pb_MPa = 208.566 # Planet.Do.ICEIh_THICKNESS = True # Planet.Bulk.zb_approximate_km = 300 # The approximate ice shell thickness desired Planet.Do.HYDROSPHERE_THICKNESS = True diff --git a/PlanetProfile/Test/PPTest28.py b/PlanetProfile/Test/PPTest28.py new file mode 100644 index 00000000..cc8b6415 --- /dev/null +++ b/PlanetProfile/Test/PPTest28.py @@ -0,0 +1,61 @@ +""" +PPTest28 +Europa-like, test using constant thermal conductivty for ice layers and ocean layers, specifying activation energy for diffusion of ice phases Ih-VI, and using a different viscosity for rock and corelayers +For testing purposes +""" +import numpy as np +from PlanetProfile.Utilities.defineStructs import PlanetStruct, Constants + +Planet = PlanetStruct('Test28') + +Planet.PfreezeUpper_MPa = 150 + +""" Bulk planetary settings """ +Planet.Bulk.R_m = 1561.0e3 +Planet.Bulk.M_kg = 4.7991e22 +Planet.Bulk.Tsurf_K = 110 +Planet.Bulk.Psurf_MPa = 0.0 +Planet.Bulk.Cmeasured = 0.346 +Planet.Bulk.Cuncertainty = 0.005 +Planet.Bulk.Tb_K = 268.4 + +""" Layer step settings """ +Planet.Steps.nIceI = 50 +Planet.Steps.nSilMax = 50 +Planet.Steps.nCore = 10 +Planet.Steps.iSilStart = Planet.Steps.nIceI + +""" Hydrosphere assumptions/settings """ +Planet.Ocean.comp = "CustomSolutionSeawater = NH4+: 0.5, Cl-: 0.5657647, Na+: 0.4860597, Mg+2: 0.0547421, Ca+2: 0.0106568, K+: 0.0105797, SO4-2: 0.0292643" +Planet.Ocean.wOcean_ppt = 0 +Planet.Ocean.deltaP = 1.0 +Planet.Ocean.PHydroMax_MPa = 250.0 +Planet.Ocean.kThermIce_WmK = {phase: 2 for phase in ['Ih', 'II', 'III', 'V', 'VI', 'Clath']} # New setting +Planet.Ocean.kThermWater_WmK = 0.6 # New setting + +""" Silicate Mantle """ +Planet.Sil.Qrad_Wkg = 5.33e-12 +Planet.Sil.Htidal_Wm3 = 1e-10 +# Rock porosity +Planet.Do.POROUS_ROCK = False +# Mantle equation of state model +Planet.Sil.mantleEOS = 'CV3hy1wt_678_1.tab' +Planet.Sil.etaRock_Pas = [1e10, 1e5] # New setting + +""" Core assumptions """ +Planet.Do.Fe_CORE = True +Planet.Core.rhoFe_kgm3 = 8000.0 +Planet.Core.rhoFeS_kgm3 = 5150.0 +Planet.Core.rhoPoFeFCC = 5455.0 +Planet.Core.QScore = 1e4 +Planet.Core.coreEOS = 'Fe-S_3D_EOS.mat' +Planet.Core.wFe_ppt = 850 +Planet.Core.xFeSmeteoritic = 0.0405 +Planet.Core.xFeS = 0.55 +Planet.Core.xFeCore = 0.0279 +Planet.Core.xH2O = 0.0035 +Planet.Core.etaFeSolid_Pas = 1e20 # New setting +Planet.Core.etaFeLiquid_Pas = 1e15 # New setting + +""" Seismic properties of solids """ +Planet.Seismic.lowQDiv = 1.0 diff --git a/PlanetProfile/Test/xRangeData.mat b/PlanetProfile/Test/xRangeData.mat new file mode 100644 index 00000000..082d1566 Binary files /dev/null and b/PlanetProfile/Test/xRangeData.mat differ diff --git a/PlanetProfile/Test/yRangeData.mat b/PlanetProfile/Test/yRangeData.mat new file mode 100644 index 00000000..082d1566 Binary files /dev/null and b/PlanetProfile/Test/yRangeData.mat differ diff --git a/PlanetProfile/Thermodynamics/ConstantEOS.py b/PlanetProfile/Thermodynamics/ConstantEOS.py new file mode 100644 index 00000000..54eb5c7d --- /dev/null +++ b/PlanetProfile/Thermodynamics/ConstantEOS.py @@ -0,0 +1,193 @@ +import os +import numpy as np +import logging +from scipy.io import loadmat +from scipy.interpolate import RectBivariateSpline, RegularGridInterpolator, interp1d as Interp1D, griddata as GridData +from PlanetProfile import _ROOT +from PlanetProfile.Utilities.defineStructs import Constants, EOSlist +from PlanetProfile.Utilities.DataManip import ResetNearestExtrap, ReturnZeros, EOSwrapper + + +class ConstantEOSStruct: + def __init__(self, constantProperties, TviscTrans_K=None, EOStype = None, innerComp = None): + if EOStype == 'inner': + self.EOStype = 'inner' + self.comp = innerComp + self.EOSlabel = f'constant{EOStype}comp{innerComp}constantProps{constantProperties}' + elif EOStype == 'ocean': + self.EOStype = 'ocean' + self.EOSlabel = f'constant{EOStype}constantProps{constantProperties}' + else: + raise ValueError(f'Invalid EOStype: {EOStype}') + self.EOSlabel = f'constant{EOStype}constantProps{constantProperties}' + if self.EOSlabel in EOSlist.loaded.keys(): + self.ALREADY_LOADED = True + else: + self.ALREADY_LOADED = False + + if not self.ALREADY_LOADED: + self.TviscTrans_K = TviscTrans_K + self.EXTRAP = True + self.Pmin = 0 + self.Pmax = 10000 + self.Tmin = 0 + self.Tmax = 10000 + + if EOStype == 'inner': + if constantProperties['kTherm_WmK'] is None: + if self.comp == 'core': + constantProperties['kTherm_WmK'] = Constants.kThermFe_WmK + elif self.comp == 'sil': + constantProperties['kTherm_WmK'] = Constants.kThermSil_WmK + if constantProperties['eta_Pas'] is None: + if self.comp == 'core': + constantProperties['eta_Pas'] = Constants.etaFeSolid_Pas + elif self.comp == 'sil': + constantProperties['eta_Pas'] = Constants.etaRock_Pas + if TviscTrans_K is None: + if self.comp == 'core': + self.TviscTrans_K = Constants.TviscFe_K + elif self.comp == 'sil': + self.TviscTrans_K = Constants.TviscRock_K + if constantProperties['GS_GPa'] is None: + if self.comp == 'core': + constantProperties['GS_GPa'] = Constants.GS_GPa[Constants.phaseFe] + elif self.comp == 'sil': + constantProperties['GS_GPa'] = Constants.GS_GPa[Constants.phaseSil] + if constantProperties['VP_kms'] is None: + if self.comp == 'core': + constantProperties['VP_kms'] = np.nan + elif self.comp == 'sil': + constantProperties['VP_kms'] = np.nan + if constantProperties['VS_kms'] is None: + if self.comp == 'core': + constantProperties['VS_kms'] = np.nan + elif self.comp == 'sil': + constantProperties['VS_kms'] = np.nan + + if constantProperties['KS_GPa'] is None: + if self.comp == 'core': + constantProperties['KS_GPa'] = np.nan + elif self.comp == 'sil': + constantProperties['KS_GPa'] = np.nan + if constantProperties['sigma_Sm'] is None: + if self.comp == 'core': + constantProperties['sigma_Sm'] = np.nan + elif self.comp == 'sil': + constantProperties['sigma_Sm'] = np.nan + + self.ufn_rho_kgm3 = returnVal(constantProperties['rho_kgm3']) + self.ufn_Cp_JkgK = returnVal(constantProperties['Cp_JkgK']) + self.ufn_alpha_pK = returnVal(constantProperties['alpha_pK']) + self.ufn_kTherm_WmK = returnVal(constantProperties['kTherm_WmK']) + self.ufn_VP_kms = returnVal(constantProperties['VP_kms']) + self.ufn_VS_kms = returnVal(constantProperties['VS_kms']) + self.ufn_KS_GPa = returnVal(constantProperties['KS_GPa']) + self.ufn_GS_GPa = returnVal(constantProperties['GS_GPa']) + self.ufn_phi_frac = ReturnZeros(1) + self.ufn_sigma_Sm = returnVal(constantProperties['sigma_Sm']) + if not isinstance(constantProperties['eta_Pas'], list): + self.ufn_eta_Pas = returnVal(constantProperties['eta_Pas']) + else: + self.ufn_eta_Pas = returnValWithThreshold(constantProperties['eta_Pas'][0], constantProperties['eta_Pas'][1], self.TviscTrans_K) + self.EOSdeltaP = None + self.EOSdeltaT = None + self.propsPmax = 0 + + + + def fn_porosCorrect(self, propBulk, propPore, phi, J): + # Combine pore fluid properties with matrix properties in accordance with + # Yu et al. (2016): http://dx.doi.org/10.1016/j.jrmge.2015.07.004 + return (propBulk**J * (1 - phi) + propPore**J * phi) ** (1/J) + def fn_phase(self, P_MPa, T_K, grid=False): + if not self.EXTRAP: + P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) + return self.ufn_phase(P_MPa, T_K, grid=grid) + def fn_rho_kgm3(self, P_MPa, T_K, grid=False): + # Limit extrapolation to use nearest value from evaluated fit if desired + if not self.EXTRAP: + P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) + return self.ufn_rho_kgm3(P_MPa, T_K, grid=grid) + def fn_Cp_JkgK(self, P_MPa, T_K, grid=False): + if not self.EXTRAP: + P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) + return self.ufn_Cp_JkgK(P_MPa, T_K, grid=grid) + def fn_alpha_pK(self, P_MPa, T_K, grid=False): + if not self.EXTRAP: + P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) + return self.ufn_alpha_pK(P_MPa, T_K, grid=grid) + def fn_kTherm_WmK(self, P_MPa, T_K, grid=False): + if not self.EXTRAP: + P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) + return self.ufn_kTherm_WmK(P_MPa, T_K, grid=grid) + def fn_VP_kms(self, P_MPa, T_K, grid=False): + if not self.EXTRAP: + P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) + return self.ufn_VP_kms(P_MPa, T_K, grid=grid) + def fn_VS_kms(self, P_MPa, T_K, grid=False): + if not self.EXTRAP: + P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) + return self.ufn_VS_kms(P_MPa, T_K, grid=grid) + def fn_KS_GPa(self, P_MPa, T_K, grid=False): + if not self.EXTRAP: + P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) + return self.ufn_KS_GPa(P_MPa, T_K, grid=grid) + def fn_GS_GPa(self, P_MPa, T_K, grid=False): + if not self.EXTRAP: + P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) + return self.ufn_GS_GPa(P_MPa, T_K, grid=grid) + def fn_phi_frac(self, P_MPa, T_K, grid=False): + if not self.EXTRAP: + P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) + return self.ufn_phi_frac(P_MPa, T_K, grid=grid) + def fn_eta_Pas(self, P_MPa, T_K, grid=False): + if not self.EXTRAP: + P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) + return self.ufn_eta_Pas(P_MPa, T_K, grid=grid) + + + + +class returnVal: + def __init__(self, val): + self.val = val + def __call__(self, P, T, grid=False): + if grid: + P, _ = np.meshgrid(P, T, indexing='ij') + return (np.ones_like(P) * self.val) + +class returnValWithThreshold: + def __init__(self, val_below, val_above, threshold_K): + self.val_below = val_below + self.val_above = val_above + self.threshold_K = threshold_K + + def __call__(self, P, T, grid=False): + if grid: + P, T_grid = np.meshgrid(P, T, indexing='ij') + result = np.where(T_grid < self.threshold_K, self.val_below, self.val_above) + else: + result = np.where(T < self.threshold_K, self.val_below, self.val_above) + return result + +class ReturnMultipleVal: + def __init__(self, vals): + self.vals = vals + def __call__(self, P, T, grid=False): + if grid: + P, _ = np.meshgrid(P, T, indexing='ij') + # Return a tuple of arrays, each filled with the corresponding value from vals + return tuple(np.ones_like(P) * val for val in self.vals) +class ReturnMultipleValWithThreshold: + def __init__(self, vals_below, vals_above, threshold_K): + self.vals_below = vals_below + self.vals_above = vals_above + self.threshold_K = threshold_K + def __call__(self, P, T, grid=False): + if grid: + P, T_grid = np.meshgrid(P, T, indexing='ij') + result = np.where(T_grid < self.threshold_K, self.vals_below, self.vals_above) + else: + result = np.where(T < self.threshold_K, self.vals_below, self.vals_above) + return result \ No newline at end of file diff --git a/PlanetProfile/Thermodynamics/Electrical.py b/PlanetProfile/Thermodynamics/Electrical.py index 9ae2b8da..f5cbb8d7 100644 --- a/PlanetProfile/Thermodynamics/Electrical.py +++ b/PlanetProfile/Thermodynamics/Electrical.py @@ -3,7 +3,8 @@ import scipy.interpolate as spi from PlanetProfile.Thermodynamics.HydroEOS import GetOceanEOS, GetIceEOS from PlanetProfile.Utilities.Indexing import GetPhaseIndices, PhaseConv, MixedPhaseSeparator -from PlanetProfile.Utilities.defineStructs import Constants, EOSlist +from PlanetProfile.Utilities.defineStructs import Constants, EOSlist, Timing +import time # Assign logger log = logging.getLogger('PlanetProfile') @@ -14,11 +15,12 @@ def ElecConduct(Planet, Params): Assigns Planet attributes: sigma_Sm """ + Timing.setFunctionTime(time.time()) # Initialize outputs as NaN so that we get errors if we missed any layers Planet.sigma_Sm = np.zeros(Planet.Steps.nTotal) * np.nan # Only perform calculations if this is a valid profile - if Planet.Do.VALID: + if Planet.Do.VALID or (Params.ALLOW_BROKEN_MODELS and Planet.Do.STILL_CALCULATE_BROKEN_PROPERTIES): # Identify which indices correspond to which phases indsLiq, indsI, indsIwet, indsII, indsIIund, indsIII, indsIIIund, indsV, indsVund, indsVI, indsVIund, \ indsClath, indsClathWet, indsMixedClathrateIh, indsMixedClathrateII, indsMixedClathrateIII, indsMixedClathrateV, indsMixedClathrateVI, \ @@ -28,14 +30,16 @@ def ElecConduct(Planet, Params): if Params.CALC_CONDUCT: # Make sure the necessary EOSs have been loaded (mainly only important in parallel ExploreOgram runs) - if not Planet.Do.NO_H2O and Planet.Ocean.EOS.key not in EOSlist.loaded.keys(): + if not (Planet.Do.NO_H2O or Planet.Do.NO_OCEAN) and Planet.Ocean.EOS.key not in EOSlist.loaded.keys(): POcean_MPa = np.arange(Planet.PfreezeLower_MPa, Planet.Ocean.PHydroMax_MPa, Planet.Ocean.deltaP) TOcean_K = np.arange(Planet.Bulk.Tb_K, Planet.Ocean.THydroMax_K, Planet.Ocean.deltaT) Planet.Ocean.EOS = GetOceanEOS(Planet.Ocean.comp, Planet.Ocean.wOcean_ppt, POcean_MPa, TOcean_K, Planet.Ocean.MgSO4elecType, rhoType=Planet.Ocean.MgSO4rhoType, scalingType=Planet.Ocean.MgSO4scalingType, FORCE_NEW=Params.FORCE_EOS_RECALC, phaseType=Planet.Ocean.phaseType, EXTRAP=Params.EXTRAP_OCEAN, - sigmaFixed_Sm=Planet.Ocean.sigmaFixed_Sm) + sigmaFixed_Sm=Planet.Ocean.sigmaFixed_Sm, kThermConst_WmK=Planet.Ocean.kThermWater_WmK, + propsStepReductionFactor=Planet.Ocean.propsStepReductionFactor) + if Planet.Do.POROUS_ICE: Planet = CalcElecPorIce(Planet, Params, indsLiq, indsI, indsIwet, indsII, indsIIund, indsIII, indsIIIund, indsV, indsVund, indsVI, indsVIund, indsClath, indsClathWet, indsMixedClathrateIh, indsMixedClathrateII, @@ -75,7 +79,7 @@ def ElecConduct(Planet, Params): Planet.Sil.sigmaPorousLayerMean_Sm = np.nan Planet.Ocean.sigmaMean_Sm = np.nan Planet.Ocean.sigmaTop_Sm = np.nan - + Timing.printFunctionTimeDifference('ElecConduct()', time.time()) return Planet @@ -111,7 +115,7 @@ def CalcElecPorIce(Planet, Params, indsLiq, indsI, indsIwet, indsII, indsIIund, Pclosure_MPa=Planet.Ocean.Pclosure_MPa[icePhase], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE[icePhase], - ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT) + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, kThermConst_WmK=Planet.Ocean.kThermIce_WmK) Planet.sigma_Sm[indsI] = Planet.Ocean.surfIceEOS['Ih'].fn_porosCorrect(Planet.Ocean.sigmaIce_Sm['Ih'], 0, Planet.phi_frac[indsI], Planet.Ocean.Jsigma) @@ -126,7 +130,7 @@ def CalcElecPorIce(Planet, Params, indsLiq, indsI, indsIwet, indsII, indsIIund, Pclosure_MPa=Planet.Ocean.Pclosure_MPa[icePhase], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE[icePhase], - ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT) + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, kThermConst_WmK=Planet.Ocean.kThermIce_WmK) Planet.sigma_Sm[indsClath] = Planet.Ocean.surfIceEOS['Clath'].fn_porosCorrect(Planet.Ocean.sigmaIce_Sm['Clath'], 0, Planet.phi_frac[indsClath], Planet.Ocean.Jsigma) @@ -142,7 +146,7 @@ def CalcElecPorIce(Planet, Params, indsLiq, indsI, indsIwet, indsII, indsIIund, Pclosure_MPa=Planet.Ocean.Pclosure_MPa[icePhase], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE[icePhase], - ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT) + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, kThermConst_WmK=Planet.Ocean.kThermIce_WmK) Planet.sigma_Sm[indsIIund] = Planet.Ocean.surfIceEOS['II'].fn_porosCorrect(Planet.Ocean.sigmaIce_Sm['II'], 0, Planet.phi_frac[indsIIund], Planet.Ocean.Jsigma) @@ -157,7 +161,7 @@ def CalcElecPorIce(Planet, Params, indsLiq, indsI, indsIwet, indsII, indsIIund, Pclosure_MPa=Planet.Ocean.Pclosure_MPa[icePhase], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE[icePhase], - ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT) + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, kThermConst_WmK=Planet.Ocean.kThermIce_WmK) Planet.sigma_Sm[indsIIIund] = Planet.Ocean.surfIceEOS['III'].fn_porosCorrect(Planet.Ocean.sigmaIce_Sm['III'], 0, Planet.phi_frac[indsIIIund], Planet.Ocean.Jsigma) @@ -172,7 +176,7 @@ def CalcElecPorIce(Planet, Params, indsLiq, indsI, indsIwet, indsII, indsIIund, Pclosure_MPa=Planet.Ocean.Pclosure_MPa[icePhase], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE[icePhase], - ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT) + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, kThermConst_WmK=Planet.Ocean.kThermIce_WmK) Planet.sigma_Sm[indsVund] = Planet.Ocean.surfIceEOS['V'].fn_porosCorrect(Planet.Ocean.sigmaIce_Sm['V'], 0, Planet.phi_frac[indsVund], Planet.Ocean.Jsigma) @@ -187,7 +191,7 @@ def CalcElecPorIce(Planet, Params, indsLiq, indsI, indsIwet, indsII, indsIIund, Pclosure_MPa=Planet.Ocean.Pclosure_MPa[icePhase], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE[icePhase], - ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT) + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, kThermConst_WmK=Planet.Ocean.kThermIce_WmK) Planet.sigma_Sm[indsVIund] = Planet.Ocean.surfIceEOS['VI'].fn_porosCorrect(Planet.Ocean.sigmaIce_Sm['VI'], 0, Planet.phi_frac[indsVIund], Planet.Ocean.Jsigma) diff --git a/PlanetProfile/Thermodynamics/Geophysical.py b/PlanetProfile/Thermodynamics/Geophysical.py index 81ee31e8..c201107d 100644 --- a/PlanetProfile/Thermodynamics/Geophysical.py +++ b/PlanetProfile/Thermodynamics/Geophysical.py @@ -175,6 +175,70 @@ def PropagateConduction(Planet, Params, iStart, iEnd): return Planet +def PropogateConductionFromDepth(Planet, Params, iStart, iEnd, Tbot_K, EOS, propogateNextLayer=True): + """ Use P, T, and layer properties as determined from conductive layer profile to evaluate + an EOS to get the layer thicknesses, gravity, and masses. + + Args: + iStart, iEnd (int): Layer array indices corresponding to the last evaluated + layer and the end of the conductive profile (e.g. material transition), + respectively. + Assigns Planet attributes: + z_m, r_m, MLayer_kg, g_ms2 + """ + + # Add a catch in case we call with invalid indices, which is convenient + # after convection calculations when no convection is happening + if iStart < iEnd: + log.debug(f'il: {iStart:d}; P_MPa: {Planet.P_MPa[iStart]:.3f}; T_K: {Planet.T_K[iStart]:.3f}; phase: {Planet.phase[iStart]:d}') + # Get initial layer properties + Planet.rhoMatrix_kgm3[iStart] = EOS.fn_rho_kgm3( Planet.P_MPa[iStart], Planet.T_K[iStart]) + Planet.Cp_JkgK[iStart] = EOS.fn_Cp_JkgK( Planet.P_MPa[iStart], Planet.T_K[iStart]) + Planet.alpha_pK[iStart] = EOS.fn_alpha_pK( Planet.P_MPa[iStart], Planet.T_K[iStart]) + Planet.kTherm_WmK[iStart] = EOS.fn_kTherm_WmK(Planet.P_MPa[iStart], Planet.T_K[iStart]) + # Calculate heat flux using Fouriers equation + q_Wm2 = Planet.kTherm_WmK[iStart] * (Tbot_K - Planet.T_K[iStart]) / (Planet.z_m[iEnd] - Planet.z_m[iStart]) + thisMAbove_kg = np.sum(Planet.MLayer_kg[:iStart]) + # Get constant gravity if we will be assigning it + if Planet.Do.CONSTANT_GRAVITY: + Planet.g_ms2[iStart+1:] = Constants.G * (Planet.Bulk.M_kg - thisMAbove_kg) / Planet.r_m[iStart]**2 + else: + # Ensure g values to be assigned are zero since we will be adding to them + Planet.g_ms2[iStart+1:] = 0 + # Assign 0 or 1 multiplier for constant/variable gravity calcs in loop + VAR_GRAV = int(not Planet.Do.CONSTANT_GRAVITY) + # Propogate from iStart+1 to start of next layer + for i in range(iStart+1, iEnd+1): + if i == iEnd and not propogateNextLayer: + continue + # Increment depth based on change in pressure, combined with gravity and density + Planet.P_MPa[i] = Planet.P_MPa[i-1] + Planet.rhoMatrix_kgm3[i-1] * Planet.g_ms2[i-1] * (Planet.z_m[i] - Planet.z_m[i-1]) * 1e-6 + + # Calculate temperature using fouriers heat flux and the assumption that the heat flux is constant + Planet.T_K[i] = Planet.T_K[i-1] + q_Wm2 * (Planet.z_m[i] - Planet.z_m[i-1]) / Planet.kTherm_WmK[i-1] + Planet.r_m[i] = Planet.Bulk.R_m - Planet.z_m[i] + Planet.MLayer_kg[i-1] = 4/3*np.pi * Planet.rhoMatrix_kgm3[i-1] * (Planet.r_m[i-1] ** 3 - Planet.r_m[i] ** 3) + thisMAbove_kg += Planet.MLayer_kg[i-1] + thisMBelow_kg = Planet.Bulk.M_kg - thisMAbove_kg + Planet.g_ms2[i] += VAR_GRAV * Constants.G * thisMBelow_kg / Planet.r_m[i]**2 + + # Now use P and T for this layer to get physical properties - except for iEnd layer, since that is the start of a new layer + if i < iEnd: + Planet.rhoMatrix_kgm3[i] = EOS.fn_rho_kgm3( Planet.P_MPa[i], Planet.T_K[i]) + Planet.Cp_JkgK[i] = EOS.fn_Cp_JkgK( Planet.P_MPa[i], Planet.T_K[i]) + Planet.alpha_pK[i] = EOS.fn_alpha_pK( Planet.P_MPa[i], Planet.T_K[i]) + Planet.kTherm_WmK[i] = EOS.fn_kTherm_WmK(Planet.P_MPa[i], Planet.T_K[i]) + + if i == iEnd: + log.debug(f'Propogating starting point for next layer...') + log.debug(f'il: {i:d}; P_MPa: {Planet.P_MPa[i]:.3f}; T_K: {Planet.T_K[i]:.3f}') + else: + log.debug(f'il: {i:d}; P_MPa: {Planet.P_MPa[i]:.3f}; T_K: {Planet.T_K[i]:.3f}; phase: {Planet.phase[i]:d}') + + Planet.rho_kgm3[iStart:iEnd+1] = Planet.rhoMatrix_kgm3[iStart:iEnd+1] + 0.0 + return Planet + + def PropagateAdiabaticSolid(Planet, Params, iStart, iEnd, EOS): """ Use layer-top values and assumption of an adiabatic thermal profile @@ -230,6 +294,69 @@ def PropagateAdiabaticSolid(Planet, Params, iStart, iEnd, EOS): return Planet +def PropagateAdiabaticSolidFromDepth(Planet, Params, iStart, iEnd, EOS): + """ Use layer-top values and assumption of an adiabatic thermal profile + to evaluate the conditions at the bottom of each layer in the specified + zone (iStart to iEnd). This function assumes no porosity. + + Args: + iStart, iEnd (int): Layer array indices corresponding to the last evaluated + layer and the end of the conductive profile (e.g. material transition), + respectively. + EOS (EOSStruct): Ice, ocean, sil, or core EOS to query for layer properties. + Assigns Planet attributes: + z_m, r_m, MLayer_kg, g_ms2, rhoMatrix_kgm3, Cp_JkgK, alpha_pK, kTherm_WmK, + rho_kgm3 + """ + # Get initial layer properties + Planet.rhoMatrix_kgm3[iStart] = EOS.fn_rho_kgm3( Planet.P_MPa[iStart], Planet.T_K[iStart]) + Planet.Cp_JkgK[iStart] = EOS.fn_Cp_JkgK( Planet.P_MPa[iStart], Planet.T_K[iStart]) + Planet.alpha_pK[iStart] = EOS.fn_alpha_pK( Planet.P_MPa[iStart], Planet.T_K[iStart]) + Planet.kTherm_WmK[iStart] = EOS.fn_kTherm_WmK(Planet.P_MPa[iStart], Planet.T_K[iStart]) + thisMAbove_kg = np.sum(Planet.MLayer_kg[:iStart-1]) + # Get constant gravity if we will be assigning it + if Planet.Do.CONSTANT_GRAVITY: + Planet.g_ms2[iStart+1:] = Constants.G * (Planet.Bulk.M_kg - thisMAbove_kg) / Planet.r_m[iStart-1]**2 + else: + # Ensure g values to be assigned are zero since we will be adding to them + Planet.g_ms2[iStart+1:] = 0 + # Assign 0 or 1 multiplier for constant/variable gravity calcs in loop + VAR_GRAV = int(not Planet.Do.CONSTANT_GRAVITY) + + for i in range(iStart+1, iEnd+1): + # Increment depth based on change in pressure, combined with gravity and density + # Increment depth based on change in pressure, combined with gravity and density + Planet.P_MPa[i] = Planet.P_MPa[i-1] + Planet.rhoMatrix_kgm3[i-1] * Planet.g_ms2[i-1] * (Planet.z_m[i] - Planet.z_m[i-1]) * 1e-6 + # Convert depth to radius + Planet.r_m[i] = Planet.Bulk.R_m - Planet.z_m[i] + # Calculate layer mass + Planet.MLayer_kg[i-1] = 4/3*np.pi * Planet.rhoMatrix_kgm3[i-1] * (Planet.r_m[i-1] ** 3 - Planet.r_m[i] ** 3) + thisMAbove_kg += Planet.MLayer_kg[i-1] + thisMBelow_kg = Planet.Bulk.M_kg - thisMAbove_kg + # Use remaining mass below in Gauss's law for gravity to get g at the top of this layer + Planet.g_ms2[i] += VAR_GRAV * Constants.G * thisMBelow_kg / Planet.r_m[i] ** 2 + + # Propagate adiabatic thermal profile + Planet.T_K[i] = Planet.T_K[i-1] + Planet.T_K[i-1] * Planet.alpha_pK[i-1] / \ + Planet.Cp_JkgK[i-1] / Planet.rhoMatrix_kgm3[i-1] * (Planet.P_MPa[i] - Planet.P_MPa[i-1]) * 1e6 + # Now use P and T for this layer to get physical properties - except for iEnd layer, since that is the start of a new layer + if i < iEnd: + Planet.rhoMatrix_kgm3[i] = EOS.fn_rho_kgm3( Planet.P_MPa[i], Planet.T_K[i]) + Planet.Cp_JkgK[i] = EOS.fn_Cp_JkgK( Planet.P_MPa[i], Planet.T_K[i]) + Planet.alpha_pK[i] = EOS.fn_alpha_pK( Planet.P_MPa[i], Planet.T_K[i]) + Planet.kTherm_WmK[i] = EOS.fn_kTherm_WmK(Planet.P_MPa[i], Planet.T_K[i]) + + if i == iEnd: + log.debug(f'Propogating starting point for next layer...') + log.debug(f'il: {i:d}; P_MPa: {Planet.P_MPa[i]:.3f}; T_K: {Planet.T_K[i]:.3f}') + else: + log.debug(f'il: {i:d}; P_MPa: {Planet.P_MPa[i]:.3f}; T_K: {Planet.T_K[i]:.3f}; phase: {Planet.phase[i]:d}') + + Planet.rho_kgm3[iStart:iEnd] = Planet.rhoMatrix_kgm3[iStart:iEnd] + 0.0 + + return Planet + + def PropagateAdiabaticPorousVacIce(Planet, Params, iStart, iEnd, EOS): """ Use layer-top values and assumption of an adiabatic thermal profile in ices to evaluate the conditions at the bottom of each layer in the specified @@ -533,7 +660,12 @@ def InitSil(Planet, Params, nProfiles, profRange, rSilEnd_m): rSil_m = np.array([np.linspace(Planet.r_m[i+Planet.Steps.iSilStart], rSilEnd_m, Planet.Steps.nSilMax+1) for i in profRange]) Psil_MPa[:,0] = [Planet.P_MPa[i+Planet.Steps.iSilStart] for i in profRange] Tsil_K[:,0] = [Planet.T_K[i+Planet.Steps.iSilStart] for i in profRange] - rhoSil_kgm3[:,0] = Planet.Sil.EOS.fn_rho_kgm3(Psil_MPa[:,0], Tsil_K[:,0]) + if Planet.Do.CONSTANT_INNER_DENSITY and not Planet.Do.Fe_CORE: + # If we are doing constant inner density and no core, we need to set the density to that calculated + rhoSil_kgm3[:,0] = Planet.Sil.rhoNoCore_kgm3 + else: + # If we are doing constant inner density w/ core or EOS-based sil, then sil density is specified by user/EOS and we set it up in EOS already + rhoSil_kgm3[:,0] = Planet.Sil.EOS.fn_rho_kgm3(Psil_MPa[:,0], Tsil_K[:,0]) kThermSil_WmK[:,0] = Planet.Sil.EOS.fn_kTherm_WmK(Psil_MPa[:,0], Tsil_K[:,0]) KSsil_GPa[:,0] = Planet.Sil.EOS.fn_KS_GPa(Psil_MPa[:,0], Tsil_K[:,0]) GSsil_GPa[:,0] = Planet.Sil.EOS.fn_GS_GPa(Psil_MPa[:,0], Tsil_K[:,0]) @@ -635,7 +767,7 @@ def InitPorous(Planet, Params, nProfiles, rSil_m0, rSil_m1, Psil_MPa0, Tsil_K0, Pclosure_MPa=Planet.Ocean.Pclosure_MPa[icePhase], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE[icePhase], - ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT) + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, kThermConst_WmK=Planet.Ocean.kThermIce_WmK) thisIceEOS = Planet.Ocean.surfIceEOS['Ih'] else: # Get ice EOS if not currently loaded @@ -648,7 +780,7 @@ def InitPorous(Planet, Params, nProfiles, rSil_m0, rSil_m1, Psil_MPa0, Tsil_K0, phiTop_frac=Planet.Ocean.phiMax_frac[icePhase], Pclosure_MPa=Planet.Ocean.Pclosure_MPa[icePhase], phiMin_frac=Planet.Ocean.phiMin_frac, - EXTRAP=Params.EXTRAP_ICE[icePhase], + EXTRAP=Params.EXTRAP_ICE[icePhase], kThermConst_WmK=Planet.Ocean.kThermIce_WmK, mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}) thisIceEOS = Planet.Ocean.iceEOS[icePhase] @@ -703,7 +835,12 @@ def SilRecursionSolid(Planet, Params, Tsil_K[:,j], qTop_Wm2 = ConductiveTemperature(Tsil_K[:,j-1], rSil_m[:,j-1], rSil_m[:,j], kThermSil_WmK[:,j-1], rhoSil_kgm3[:,j-1], Planet.Sil.Qrad_Wkg, HtidalSil_Wm3[:,j-1], qTop_Wm2) - rhoSil_kgm3[:,j] = Planet.Sil.EOS.fn_rho_kgm3(Psil_MPa[:,j], Tsil_K[:,j]) + if Planet.Do.CONSTANT_INNER_DENSITY and not Planet.Do.Fe_CORE: + # If we are doing constant inner density and no core, we need to set the density to that calculated + rhoSil_kgm3[:,j] = Planet.Sil.rhoNoCore_kgm3 + else: + # If we are doing constant inner density, then sil density is specified by user and we set it up in EOS already + rhoSil_kgm3[:,j] = Planet.Sil.EOS.fn_rho_kgm3(Psil_MPa[:,j], Tsil_K[:,j]) kThermSil_WmK[:,j] = Planet.Sil.EOS.fn_kTherm_WmK(Psil_MPa[:,j], Tsil_K[:,j]) # Get KS and GS now as they are needed for Htidal calculation; # we will calculate them again later along with other seismic calcs @@ -744,7 +881,12 @@ def SilRecursionPorous(Planet, Params, kThermSil_WmK[:,j-1], rhoSil_kgm3[:,j-1], Planet.Sil.Qrad_Wkg, HtidalSil_Wm3[:,j-1], qTop_Wm2) # Get matrix material physical properties - rhoSil_kgm3[:,j] = Planet.Sil.EOS.fn_rho_kgm3(Psil_MPa[:,j], Tsil_K[:,j]) + if Planet.Do.CONSTANT_INNER_DENSITY and not Planet.Do.Fe_CORE: + # If we are doing constant inner density and no core, we need to set the density to that calculated + rhoSil_kgm3[:,j] = Planet.Sil.rhoNoCore_kgm3 + else: + # If we are doing constant inner density, then sil density is specified by user and we set it up in EOS already + rhoSil_kgm3[:,j] = Planet.Sil.EOS.fn_rho_kgm3(Psil_MPa[:,j], Tsil_K[:,j]) kThermSil_WmK[:,j] = Planet.Sil.EOS.fn_kTherm_WmK(Psil_MPa[:,j], Tsil_K[:,j]) # Get KS and GS now as they are needed for Htidal calculation; # we will calculate them again later along with other seismic calcs @@ -801,7 +943,7 @@ def SilRecursionPorous(Planet, Params, Pclosure_MPa=Planet.Ocean.Pclosure_MPa[icePhase], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE[icePhase], - ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT) + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, kThermConst_WmK=Planet.Ocean.kThermIce_WmK) thisIceEOS = Planet.Ocean.surfIceEOS['Ih'] else: # Get ice EOS if not currently loaded @@ -814,7 +956,7 @@ def SilRecursionPorous(Planet, Params, phiTop_frac=Planet.Ocean.phiMax_frac[icePhase], Pclosure_MPa=Planet.Ocean.Pclosure_MPa[icePhase], phiMin_frac=Planet.Ocean.phiMin_frac, - EXTRAP=Params.EXTRAP_ICE[icePhase], + EXTRAP=Params.EXTRAP_ICE[icePhase], kThermConst_WmK=Planet.Ocean.kThermIce_WmK, mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}) thisIceEOS = Planet.Ocean.iceEOS[icePhase] diff --git a/PlanetProfile/Thermodynamics/HydroEOS.py b/PlanetProfile/Thermodynamics/HydroEOS.py index fecfda37..0b7785ee 100644 --- a/PlanetProfile/Thermodynamics/HydroEOS.py +++ b/PlanetProfile/Thermodynamics/HydroEOS.py @@ -1,5 +1,6 @@ import numpy as np import logging +import time from copy import deepcopy from scipy.interpolate import RegularGridInterpolator, RectBivariateSpline from scipy.optimize import root_scalar as GetZero @@ -7,32 +8,35 @@ from seafreeze.seafreeze import whichphase as WhichPhase from PlanetProfile.Thermodynamics.Clathrates.ClathrateProps import ClathProps, ClathStableSloan1998, \ ClathStableNagashima2017, ClathSeismic -from PlanetProfile.Utilities.DataManip import ResetNearestExtrap, ReturnZeros, EOSwrapper, ReturnConstantSpecies +from PlanetProfile.Utilities.DataManip import ResetNearestExtrap, ReturnZeros, EOSwrapper, ReturnConstantSpecies, ReAssignPT, Nearest2DInterpolator as PhaseInterpolator from PlanetProfile.Thermodynamics.InnerEOS import GetphiFunc, GetphiCalc from PlanetProfile.Thermodynamics.MgSO4.MgSO4Props import MgSO4Props, MgSO4PhaseMargules, MgSO4PhaseLookup, \ MgSO4Seismic, MgSO4Conduct, Ppt2molal from PlanetProfile.Thermodynamics.Seawater.SwProps import SwProps, SwPhase, SwSeismic, SwConduct -from PlanetProfile.Utilities.defineStructs import Constants, EOSlist +from PlanetProfile.Utilities.defineStructs import Constants, EOSlist, Timing from PlanetProfile.Utilities.Indexing import PhaseConv, PhaseInv, MixedPhaseSeparator from PlanetProfile.Thermodynamics.Reaktoro.reaktoroProps import RktPhaseLookup, RktPhaseOnDemand, \ - SpeciesParser, RktProps, RktSeismic, RktConduct, RktHydroSpecies, RktRxnAffinity, EOSLookupTableLoader -# Assign logger + SpeciesParser, RktProps, RktSeismic, RktConduct, RktHydroSpecies, EOSLookupTableLoader +from PlanetProfile.Thermodynamics.Seafreeze.SeafreezeProps import IceSeaFreezeProps +# Assign logger log = logging.getLogger('PlanetProfile') def GetOceanEOS(compstr, wOcean_ppt, P_MPa, T_K, elecType, rhoType=None, scalingType=None, phaseType=None, EXTRAP=False, FORCE_NEW=False, MELT=False, PORE=False, sigmaFixed_Sm=None, LOOKUP_HIRES=False, - etaFixed_Pas=None): + etaFixed_Pas=None, kThermConst_WmK=None, doConstantProps=False, constantProperties=None, propsStepReductionFactor=1): oceanEOS = OceanEOSStruct(compstr, wOcean_ppt, P_MPa, T_K, elecType, rhoType=rhoType, scalingType=scalingType, phaseType=phaseType, EXTRAP=EXTRAP, FORCE_NEW=FORCE_NEW, MELT=MELT, PORE=PORE, - sigmaFixed_Sm=sigmaFixed_Sm, LOOKUP_HIRES=LOOKUP_HIRES, etaFixed_Pas=etaFixed_Pas) + sigmaFixed_Sm=sigmaFixed_Sm, LOOKUP_HIRES=LOOKUP_HIRES, etaFixed_Pas=etaFixed_Pas, kThermConst_WmK=kThermConst_WmK, + doConstantProps=doConstantProps, constantProperties=constantProperties, propsStepReductionFactor=propsStepReductionFactor) if oceanEOS.ALREADY_LOADED and not FORCE_NEW: log.debug(f'{wOcean_ppt} ppt {compstr} EOS already loaded. Reusing existing EOS.') oceanEOS = EOSlist.loaded[oceanEOS.EOSlabel] # Ensure each EOSlabel is included in EOSlist, in case we have reused EOSs with # e.g. a smaller range that can reuse the larger-range already-loaded EOS. - if oceanEOS.EOSlabel not in EOSlist.loaded.keys() or FORCE_NEW: + if oceanEOS.EOSlabel not in EOSlist.loaded.keys(): EOSlist.loaded[oceanEOS.EOSlabel] = oceanEOS + EOSlist.ranges[oceanEOS.EOSlabel] = oceanEOS.rangeLabel oceanEOSwrapper = EOSwrapper(oceanEOS.EOSlabel) @@ -41,7 +45,8 @@ def GetOceanEOS(compstr, wOcean_ppt, P_MPa, T_K, elecType, rhoType=None, scaling class OceanEOSStruct: def __init__(self, compstr, wOcean_ppt, P_MPa, T_K, elecType, rhoType=None, scalingType=None, phaseType=None, EXTRAP=False, FORCE_NEW=False, MELT=False, PORE=False, - sigmaFixed_Sm=None, LOOKUP_HIRES=False, etaFixed_Pas=None): + sigmaFixed_Sm=None, LOOKUP_HIRES=False, etaFixed_Pas=None, kThermConst_WmK=None, doConstantProps=False, constantProperties=None, propsStepReductionFactor=1): + Timing.setTime(time.time()) if elecType is None: self.elecType = 'Vance2018' else: @@ -54,21 +59,30 @@ def __init__(self, compstr, wOcean_ppt, P_MPa, T_K, elecType, rhoType=None, scal self.scalingType = 'Vance2018' else: self.scalingType = scalingType - if phaseType is None or phaseType == 'lookup': + if phaseType is None: + phaseType = 'lookup' + if phaseType.lower() == 'lookup': self.PHASE_LOOKUP = True + self.PHASE_PRELOAD = False + elif phaseType.lower() == 'preload': + self.PHASE_PRELOAD = True + self.PHASE_LOOKUP = False else: self.PHASE_LOOKUP = False + self.PHASE_PRELOAD = False + if kThermConst_WmK is None: + kThermConst_WmK = Constants.kThermWater_WmK + else: + kThermConst_WmK = kThermConst_WmK # Add ID for melting curve EOS if MELT: - meltStr = f'melt{np.max(P_MPa)}' + meltStr = f'melt' meltPrint = 'melting curve ' else: meltStr = '' meltPrint = '' - self.EOSlabel = f'{meltStr}Comp{compstr}wppt{wOcean_ppt}elec{elecType}rho{rhoType}' + \ - f'scaling{scalingType}phase{phaseType}extrap{EXTRAP}pore{PORE}' + \ - f'hires{LOOKUP_HIRES}etaFixed{etaFixed_Pas}' + self.EOSlabel = GetOceanEOSLabel(compstr, wOcean_ppt, elecType, rhoType, scalingType, phaseType, EXTRAP, PORE, LOOKUP_HIRES, etaFixed_Pas, meltStr, propsStepReductionFactor) self.ALREADY_LOADED, self.rangeLabel, P_MPa, T_K, self.deltaP, self.deltaT \ = CheckIfEOSLoaded(self.EOSlabel, P_MPa, T_K, FORCE_NEW=FORCE_NEW) @@ -82,212 +96,254 @@ def __init__(self, compstr, wOcean_ppt, P_MPa, T_K, elecType, rhoType=None, scal self.Pmax = np.max(P_MPa) self.Tmin = np.min(T_K) self.Tmax = np.max(T_K) - if self.w_ppt is None: - wStr = '0.0' - else: - wStr = f'{self.w_ppt:.1f}' - log.debug(f'Loading {meltPrint}EOS for {wStr} ppt {self.comp} with ' + - f'P_MPa = [{self.Pmin:.1f}, {self.Pmax:.1f}, {self.deltaP:.3f}], ' + - f'T_K = [{self.Tmin:.1f}, {self.Tmax:.1f}, {self.deltaT:.3f}], ' + - f'for [min, max, step] with EXTRAP = {self.EXTRAP}.') - - # Get tabular data from the appropriate source for the specified ocean composition - if self.comp == 'none': + if doConstantProps: self.ufn_phase = ReturnZeros(1) - self.type = 'No H2O' - self.m_gmol = np.nan - rho_kgm3 = np.zeros((np.size(P_MPa), np.size(T_K))) - Cp_JkgK = rho_kgm3 - alpha_pK = rho_kgm3 - kTherm_WmK = rho_kgm3 - self.ufn_Seismic = ReturnZeros(2) - self.ufn_sigma_Sm = ReturnZeros(1) + self.ufn_rho_kgm3 = returnVal(constantProperties['rho_kgm3']) + self.ufn_Cp_JkgK = returnVal(constantProperties['Cp_JkgK']) + self.ufn_alpha_pK = returnVal(constantProperties['alpha_pK']) + self.ufn_kTherm_WmK = returnVal(constantProperties['kTherm_WmK']) + self.ufn_Seismic = ReturnMultipleVal([constantProperties['VP_kms'], constantProperties['VS_kms']]) + self.ufn_sigma_Sm = returnVal(constantProperties['sigma_Sm']) + self.ufn_eta_Pas = returnVal(constantProperties['eta_Pas']) self.EOSdeltaP = None self.EOSdeltaT = None self.propsPmax = 0 - elif self.comp in ['PureH2O', 'NH3', 'NaCl']: - self.type = 'SeaFreeze' - self.m_gmol = Constants.m_gmol[self.comp] - - # Set extrapolation boundaries to limits defined in SeaFreeze - Pmax = {'PureH2O': 2300.6, 'NH3': 2228.4, 'NaCl': 1000.1} - #Pmax = {'PureH2O': 2300.6, 'NH3': 2228.4, 'NaCl': 5000.1} - Tmin = {'PureH2O': 239, 'NH3': 241, 'NaCl': 229.0} - Tmax = {'PureH2O': 501, 'NH3': 399.2, 'NaCl': 501.0} - wMax = {'PureH2O': np.nan, 'NH3': 290.1, 'NaCl': 290.3} # upper NaCl concentration is 7mol/kgH2O - self.Pmax = np.minimum(self.Pmax, Pmax[self.comp]) - self.Tmin = np.maximum(self.Tmin, Tmin[self.comp]) - self.Tmax = np.minimum(self.Tmax, Tmax[self.comp]) - self.propsPmax = self.Pmax - if np.size(P_MPa) == np.size(T_K): - log.warning(f'Both P and T inputs have length {np.size(P_MPa)}, but they are organized to be ' + - 'used as a grid. This will cause an error in SeaFreeze. P list will be adjusted slightly.') - P_MPa = np.linspace(P_MPa[0], P_MPa[-1], np.size(P_MPa)+1) - if np.max(P_MPa) > self.Pmax: - log.warning(f'Input Pmax greater than SeaFreeze limit for {self.comp}. Resetting to SF max of {self.Pmax} MPa.') - P_MPa = np.linspace(np.min(P_MPa), self.Pmax, np.size(P_MPa)) - if np.min(T_K) < self.Tmin: - log.warning(f'Input Tmin less than SeaFreeze limit for {self.comp}. Resetting to SF min of {self.Tmin} K.') - T_K = np.linspace(self.Tmin, np.max(T_K), np.size(T_K)) - if np.max(T_K) > self.Tmax: - log.warning(f'Input Tmax greater than SeaFreeze limit for {self.comp}. Resetting to SF max of {self.Tmax} K.') - T_K = np.linspace(np.min(T_K), self.Tmax, np.size(T_K)) - - if self.comp == 'PureH2O': - SFcomp = 'water1' - PTmGrid = sfPTgrid(P_MPa, T_K) - self.ufn_sigma_Sm = H2Osigma_Sm(sigmaFixed_Sm) + else: + if self.w_ppt is None: + wStr = '0.0' + else: + wStr = f'{self.w_ppt:.1f}' + log.debug(f'Loading {meltPrint}EOS for {wStr} ppt {self.comp} with ' + + f'P_MPa = [{self.Pmin:.1f}, {self.Pmax:.1f}, {self.deltaP:.3f}], ' + + f'T_K = [{self.Tmin:.1f}, {self.Tmax:.1f}, {self.deltaT:.3f}], ' + + f'for [min, max, step] with EXTRAP = {self.EXTRAP}.') + # If we are doing melt, we only need to use high fidelity P_MPa and T_K for phase grid, since we won't use it to query any thermodynamic properties + # This will reduce runtime and memory usage for high resolution grids + if MELT: + PropsP_MPa, PropsT_K, Pphase_MPa, Tphase_K = ReAssignPT(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax, MELT=True) + elif propsStepReductionFactor > 1: + PropsP_MPa, PropsT_K, Pphase_MPa, Tphase_K = ReAssignPT(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax, MELT=False, propsStepReductionFactor=propsStepReductionFactor) else: - if self.w_ppt > wMax[self.comp]: - log.warning(f'Input wOcean_ppt greater than SeaFreeze limit for {self.comp}. Resetting to SF max.') - self.w_ppt = wMax[self.comp] - if self.comp == 'NaCl': - SFcomp = 'NaClaq' + PropsP_MPa, PropsT_K, Pphase_MPa, Tphase_K = P_MPa, T_K, P_MPa, T_K + # Get tabular data from the appropriate source for the specified ocean composition + if self.comp == 'none': + self.ufn_phase = ReturnZeros(1) + self.type = 'No H2O' + self.m_gmol = np.nan + rho_kgm3 = np.zeros((np.size(PropsP_MPa), np.size(PropsT_K))) + Cp_JkgK = rho_kgm3 + alpha_pK = rho_kgm3 + kTherm_WmK = rho_kgm3 + self.ufn_Seismic = ReturnZeros(2) + self.ufn_sigma_Sm = ReturnZeros(1) + self.EOSdeltaP = None + self.EOSdeltaT = None + self.propsPmax = 0 + elif self.comp in ['PureH2O', 'NH3', 'NaCl']: + self.type = 'SeaFreeze' + self.m_gmol = Constants.m_gmol[self.comp] + + # Set extrapolation boundaries to limits defined in SeaFreeze + Pmax = {'PureH2O': 2300.6, 'NH3': 2228.4, 'NaCl': 1000.1} + #Pmax = {'PureH2O': 2300.6, 'NH3': 2228.4, 'NaCl': 5000.1} + Tmin = {'PureH2O': 239, 'NH3': 241, 'NaCl': 229.0} + Tmax = {'PureH2O': 501, 'NH3': 399.2, 'NaCl': 501.0} + wMax = {'PureH2O': np.nan, 'NH3': 290.1, 'NaCl': 290.3} # upper NaCl concentration is 7mol/kgH2O + self.Pmax = np.minimum(self.Pmax, Pmax[self.comp]) + self.Tmin = np.maximum(self.Tmin, Tmin[self.comp]) + self.Tmax = np.minimum(self.Tmax, Tmax[self.comp]) + self.propsPmax = self.Pmax + if np.max(P_MPa) > self.Pmax: + log.warning(f'Input Pmax greater than SeaFreeze limit for {self.comp}. Resetting to SF max of {self.Pmax} MPa.') + P_MPa = np.linspace(np.min(P_MPa), self.Pmax, np.size(P_MPa)) + PropsP_MPa, PropsT_K, Pphase_MPa, Tphase_K = ReAssignPT(P_MPa, T_K, P_MPa[0], self.Pmax, T_K[0], T_K[-1], MELT=MELT, propsStepReductionFactor=propsStepReductionFactor) + if np.min(T_K) < self.Tmin: + log.warning(f'Input Tmin less than SeaFreeze limit for {self.comp}. Resetting to SF min of {self.Tmin} K.') + T_K = np.linspace(self.Tmin, np.max(T_K), np.size(T_K)) + PropsP_MPa, PropsT_K, Pphase_MPa, Tphase_K = ReAssignPT(P_MPa, T_K, P_MPa[0], P_MPa[-1], T_K[0], T_K[-1], MELT=MELT, propsStepReductionFactor=propsStepReductionFactor) + if np.max(T_K) > self.Tmax: + log.warning(f'Input Tmax greater than SeaFreeze limit for {self.comp}. Resetting to SF max of {self.Tmax} K.') + T_K = np.linspace(np.min(T_K), self.Tmax, np.size(T_K)) + PropsP_MPa, PropsT_K, Pphase_MPa, Tphase_K = ReAssignPT(P_MPa, T_K, P_MPa[0], P_MPa[-1], T_K[0], T_K[-1], MELT=MELT, propsStepReductionFactor=propsStepReductionFactor) + if np.size(P_MPa) == np.size(T_K): + log.warning(f'Both P and T inputs have length {np.size(P_MPa)}, but they are organized to be ' + + 'used as a grid. This will cause an error in SeaFreeze. P list will be adjusted slightly.') + P_MPa = np.linspace(P_MPa[0], P_MPa[-1], np.size(P_MPa)+1) + PropsP_MPa, PropsT_K, Pphase_MPa, Tphase_K = ReAssignPT(P_MPa, T_K, P_MPa[0], P_MPa[-1], T_K[0], T_K[-1], MELT=MELT, propsStepReductionFactor=propsStepReductionFactor) + if self.comp == 'PureH2O': + SFcomp = 'water1' + PTmGridProps = sfPTgrid(PropsP_MPa, PropsT_K) + PTmGridPhase = sfPTgrid(Pphase_MPa, Tphase_K) self.ufn_sigma_Sm = H2Osigma_Sm(sigmaFixed_Sm) else: - SFcomp = self.comp - self.ufn_sigma_Sm = H2Osigma_Sm(sigmaFixed_Sm) # Placeholder until lab data can be implemented - if self.w_ppt > wMax[self.comp]: - log.warning(f'Input wOcean_ppt greater than SeaFreeze limit for {self.comp}. Resetting to SF max.') - self.w_ppt = wMax[self.comp] - PTmGrid = sfPTmGrid(P_MPa, T_K, Ppt2molal(self.w_ppt, self.m_gmol)) - seaOut = SeaFreeze(deepcopy(PTmGrid), SFcomp) - rho_kgm3 = seaOut.rho - Cp_JkgK = seaOut.Cp - alpha_pK = seaOut.alpha - kTherm_WmK = np.zeros_like(alpha_pK) + Constants.kThermWater_WmK # Placeholder until we implement a self-consistent calculation - - if self.PHASE_LOOKUP: - if self.comp == 'PureH2O': - self.phase = WhichPhase(deepcopy(PTmGrid)) # FOR COMPATIBILITY WITH SF v0.9.2: Use default comp of water1 here. This is not robust, but allows support for in-development updates to SeaFreeze. + if self.w_ppt > wMax[self.comp]: + log.warning(f'Input wOcean_ppt greater than SeaFreeze limit for {self.comp}. Resetting to SF max.') + self.w_ppt = wMax[self.comp] + if self.comp == 'NaCl': + SFcomp = 'NaClaq' + self.ufn_sigma_Sm = H2Osigma_Sm(sigmaFixed_Sm) + else: + SFcomp = self.comp + self.ufn_sigma_Sm = H2Osigma_Sm(sigmaFixed_Sm) # Placeholder until lab data can be implemented + if self.w_ppt > wMax[self.comp]: + log.warning(f'Input wOcean_ppt greater than SeaFreeze limit for {self.comp}. Resetting to SF max.') + self.w_ppt = wMax[self.comp] + PTmGridProps = sfPTmGrid(PropsP_MPa, PropsT_K, Ppt2molal(self.w_ppt, self.m_gmol)) + PTmGridPhase = sfPTmGrid(Pphase_MPa, Tphase_K, Ppt2molal(self.w_ppt, self.m_gmol)) + seaOut = SeaFreeze(deepcopy(PTmGridProps), SFcomp) + rho_kgm3 = seaOut.rho + Cp_JkgK = seaOut.Cp + alpha_pK = seaOut.alpha + kTherm_WmK = np.zeros_like(alpha_pK) + kThermConst_WmK # Placeholder until we implement a self-consistent calculation + + if self.PHASE_LOOKUP: + if self.comp == 'PureH2O': + self.phase = WhichPhase(deepcopy(PTmGridPhase)) # FOR COMPATIBILITY WITH SF v0.9.2: Use default comp of water1 here. This is not robust, but allows support for in-development updates to SeaFreeze. + else: + self.phase = WhichPhase(deepcopy(PTmGridPhase), solute=SFcomp) + # Create phase finder -- note that the results from this function must be cast to int after retrieval + self.ufn_phase = PhaseInterpolator(Pphase_MPa, Tphase_K, self.phase) + # Save EOS grid resolution in lookup table + self.EOSdeltaP = self.deltaP + self.EOSdeltaT = self.deltaT else: - self.phase = WhichPhase(deepcopy(PTmGrid), solute=SFcomp) - # Create phase finder -- note that the results from this function must be cast to int after retrieval - self.ufn_phase = RGIwrap(RegularGridInterpolator((P_MPa, T_K), self.phase, method='nearest'), - self.deltaP, self.deltaT) - # Save EOS grid resolution in lookup table - self.EOSdeltaP = self.deltaP - self.EOSdeltaT = self.deltaT - else: - self.ufn_phase = SFphase(self.w_ppt, self.comp) - # Lookup table is not used -- flag with nan for grid resolution. - self.EOSdeltaP = np.nan - self.EOSdeltaT = np.nan + self.ufn_phase = SFphase(self.w_ppt, self.comp) + # Lookup table is not used -- flag with nan for grid resolution. + self.EOSdeltaP = np.nan + self.EOSdeltaT = np.nan - self.ufn_Seismic = SFSeismic(self.comp, P_MPa, T_K, seaOut, self.w_ppt, self.EXTRAP) - if self.comp == 'PureH2O': - Ocean_Speciation_Info = Constants.KnownCompositions['PureH2O'] - elif self.comp == 'NaCl': - Ocean_Speciation_Info = Constants.KnownCompositions['NaCl'] - else: - # Placeholder until we get other species - Ocean_Speciation_Info = {'ppt_reference_g_kg': None, 'pH': None, 'species': None} - self.ufn_species = ReturnConstantSpecies(wOcean_ppt, Ocean_Speciation_Info['ppt_reference_g_kg'], - Ocean_Speciation_Info['pH'], Ocean_Speciation_Info['species']) - - elif self.comp == 'Seawater': - self.type = 'GSW' - self.m_gmol = Constants.m_gmol['H2O'] - if((self.Tmin <= 250) or (self.Pmax > Constants.PminHPices_MPa)): - log.warning('GSW handles only ice Ih for determining phases in the ocean. At ' + - 'low temperatures or high pressures, this model will be wrong as no ' + - 'high-pressure ice phases will be found.') - self.Pmax = Constants.PminHPices_MPa - if self.Tmax > 350: - log.warning('GSW yields physically valid properties only up to about 350 K. ' + - 'Maximum temperature for this Seawater EOS will be set to that value.') - self.Tmax = 350 - - self.ufn_phase = SwPhase(self.w_ppt) - # Lookup table is not used -- flag with nan for grid resolution. - self.EOSdeltaP = np.nan - self.EOSdeltaT = np.nan - rho_kgm3, Cp_JkgK, alpha_pK, kTherm_WmK = SwProps(P_MPa, T_K, self.w_ppt) - self.ufn_Seismic = SwSeismic(self.w_ppt, self.EXTRAP) - if sigmaFixed_Sm is not None: - self.ufn_sigma_Sm = H2Osigma_Sm(sigmaFixed_Sm) - else: - self.ufn_sigma_Sm = SwConduct(self.w_ppt) - self.propsPmax = self.Pmax - Ocean_Speciation_Info = Constants.KnownCompositions['Seawater'] - self.ufn_species = ReturnConstantSpecies(wOcean_ppt, Ocean_Speciation_Info['ppt_reference_g_kg'], - Ocean_Speciation_Info['pH'], Ocean_Speciation_Info['species']) - elif self.comp == 'MgSO4': - if self.elecType == 'Pan2020' and round(self.w_ppt) != 100: - log.warning('elecType "Pan2020" behavior is defined only for Ocean.wOcean_ppt = 100. ' + - 'Defaulting to elecType "Vance2018".') - self.elecType = 'Vance2018' - self.type = 'ChoukronGrasset2010' - self.m_gmol = Constants.m_gmol['MgSO4'] - P_MPa, T_K, rho_kgm3, Cp_JkgK, alpha_pK, kTherm_WmK \ - = MgSO4Props(P_MPa, T_K, self.w_ppt, self.EXTRAP) - if self.PHASE_LOOKUP: - self.ufn_phase = MgSO4PhaseLookup(self.w_ppt, HIRES=LOOKUP_HIRES) - self.phasePmax = self.ufn_phase.Pmax - # Save EOS grid resolution from MgSO4 lookup table loaded from disk - self.EOSdeltaP = self.ufn_phase.deltaP - self.EOSdeltaT = self.ufn_phase.deltaT - else: - Margules = MgSO4PhaseMargules(self.w_ppt) - self.ufn_phase = Margules.arrays - self.phasePmax = Margules.Pmax + self.ufn_Seismic = SFSeismic(self.comp, PropsP_MPa, PropsT_K, seaOut, self.w_ppt, self.EXTRAP) + if self.comp == 'PureH2O': + Ocean_Speciation_Info = Constants.KnownCompositions['PureH2O'] + elif self.comp == 'NaCl': + Ocean_Speciation_Info = Constants.KnownCompositions['NaCl'] + else: + # Placeholder until we get other species + Ocean_Speciation_Info = {'ppt_reference_g_kg': None, 'pH': None, 'species': None} + self.ufn_species = ReturnConstantSpecies(wOcean_ppt, Ocean_Speciation_Info['ppt_reference_g_kg'], + Ocean_Speciation_Info['pH'], Ocean_Speciation_Info['species']) + + elif self.comp == 'Seawater': + self.type = 'GSW' + self.m_gmol = Constants.m_gmol['H2O'] + if((self.Tmin <= 250) or (self.Pmax > Constants.PminHPices_MPa)): + log.warning('GSW handles only ice Ih for determining phases in the ocean. At ' + + 'low temperatures or high pressures, this model will be wrong as no ' + + 'high-pressure ice phases will be found.') + self.Pmax = Constants.PminHPices_MPa + if self.Tmax > 350: + log.warning('GSW yields physically valid properties only up to about 350 K. ' + + 'Maximum temperature for this Seawater EOS will be set to that value.') + self.Tmax = 350 + + self.ufn_phase = SwPhase(self.w_ppt) # Lookup table is not used -- flag with nan for grid resolution. self.EOSdeltaP = np.nan self.EOSdeltaT = np.nan - # self.ufn_species = - self.ufn_Seismic = MgSO4Seismic(self.w_ppt, self.EXTRAP) - if sigmaFixed_Sm is not None: - self.ufn_sigma_Sm = H2Osigma_Sm(sigmaFixed_Sm) - else: - self.ufn_sigma_Sm = MgSO4Conduct(self.w_ppt, self.elecType, rhoType=self.rhoType, - scalingType=self.scalingType) - self.propsPmax = self.ufn_Seismic.Pmax - self.Pmax = np.min([self.Pmax, self.phasePmax]) - Ocean_Speciation_Info = Constants.KnownCompositions['MgSO4'] - self.ufn_species = ReturnConstantSpecies(wOcean_ppt, Ocean_Speciation_Info['ppt_reference_g_kg'], - Ocean_Speciation_Info['pH'], Ocean_Speciation_Info['species']) - elif self.comp.startswith("CustomSolution"): - # Parse out the species list and ratio into a format compatible with Reaktoro and create a CustomSolution EOS label - self.aqueous_species_string, self.speciation_ratio_mol_kg, self.ocean_solid_phases, self.EOS_lookup_label = SpeciesParser(self.comp, self.w_ppt) - - EOSLookupTable = EOSLookupTableLoader(self.aqueous_species_string, self.speciation_ratio_mol_kg, self.ocean_solid_phases, self.EOS_lookup_label) - self.type = 'Reaktoro' - P_MPa, T_K, rho_kgm3, Cp_JkgK, alpha_pK, kTherm_WmK, self.EOSdeltaP, self.EOSdeltaT = ( - RktProps(EOSLookupTable, P_MPa, T_K, self.EXTRAP)) - self.ufn_Seismic = RktSeismic(EOSLookupTable, self.EXTRAP) - - - self.ufn_phase = RktPhaseLookup(EOSLookupTable, P_MPa, T_K, self.deltaP, self.deltaT) - - self.ufn_species = RktHydroSpecies(self.aqueous_species_string, self.speciation_ratio_mol_kg, self.ocean_solid_phases) - self.ufn_rxn_affinity = RktRxnAffinity(self.aqueous_species_string, self.speciation_ratio_mol_kg, self.ocean_solid_phases) - if sigmaFixed_Sm is not None: - self.ufn_sigma_Sm = H2Osigma_Sm(sigmaFixed_Sm) + rho_kgm3, Cp_JkgK, alpha_pK, kTherm_WmK = SwProps(PropsP_MPa, PropsT_K, self.w_ppt) + self.ufn_Seismic = SwSeismic(self.w_ppt, self.EXTRAP) + if sigmaFixed_Sm is not None: + self.ufn_sigma_Sm = H2Osigma_Sm(sigmaFixed_Sm) + else: + self.ufn_sigma_Sm = SwConduct(self.w_ppt) + self.propsPmax = self.Pmax + Ocean_Speciation_Info = Constants.KnownCompositions['Seawater'] + self.ufn_species = ReturnConstantSpecies(wOcean_ppt, Ocean_Speciation_Info['ppt_reference_g_kg'], + Ocean_Speciation_Info['pH'], Ocean_Speciation_Info['species']) + elif self.comp == 'MgSO4': + if self.elecType == 'Pan2020' and round(self.w_ppt) != 100: + log.warning('elecType "Pan2020" behavior is defined only for Ocean.wOcean_ppt = 100. ' + + 'Defaulting to elecType "Vance2018".') + self.elecType = 'Vance2018' + self.type = 'ChoukronGrasset2010' + self.m_gmol = Constants.m_gmol['MgSO4'] + PropsP_MPa, PropsT_K, rho_kgm3, Cp_JkgK, alpha_pK, kTherm_WmK \ + = MgSO4Props(PropsP_MPa, PropsT_K, self.w_ppt, self.EXTRAP) + if self.PHASE_PRELOAD: + self.ufn_phase = MgSO4PhaseLookup(self.w_ppt, HIRES=LOOKUP_HIRES) + self.phasePmax = self.ufn_phase.Pmax + # Save EOS grid resolution from MgSO4 lookup table loaded from disk + self.EOSdeltaP = self.ufn_phase.deltaP + self.EOSdeltaT = self.ufn_phase.deltaT + elif self.PHASE_LOOKUP: + Margules = MgSO4PhaseMargules(Pphase_MPa, Tphase_K, self.w_ppt) + self.ufn_phase = Margules.fn_phase + self.phasePmax = Margules.Pmax + # Lookup table is not used -- flag with nan for grid resolution. + self.EOSdeltaP = np.nan + self.EOSdeltaT = np.nan + else: + Margules = MgSO4PhaseMargules(Pphase_MPa, Tphase_K, self.w_ppt) + self.ufn_phase = Margules.fn_phase + self.phasePmax = Margules.Pmax + # Lookup table is not used -- flag with nan for grid resolution. + self.EOSdeltaP = np.nan + self.EOSdeltaT = np.nan + # self.ufn_species = + self.ufn_Seismic = MgSO4Seismic(self.w_ppt, self.EXTRAP) + if sigmaFixed_Sm is not None or wOcean_ppt == 0: + # If wOcean_ppt == 0, we are in pure water mode and should use the default conductivity of pure water (the MgSO4Conduct implementation calculates actual conductivity values at 0.0ppt that don't make sense) + self.ufn_sigma_Sm = H2Osigma_Sm(sigmaFixed_Sm) + else: + self.ufn_sigma_Sm = MgSO4Conduct(self.w_ppt, self.elecType, rhoType=self.rhoType, + scalingType=self.scalingType) + self.propsPmax = self.ufn_Seismic.Pmax + self.Pmax = np.min([self.Pmax, self.phasePmax]) + Ocean_Speciation_Info = Constants.KnownCompositions['MgSO4'] + self.ufn_species = ReturnConstantSpecies(wOcean_ppt, Ocean_Speciation_Info['ppt_reference_g_kg'], + Ocean_Speciation_Info['pH'], Ocean_Speciation_Info['species']) + elif self.comp.startswith("CustomSolution"): + # Parse out the species list and ratio into a format compatible with Reaktoro and create a CustomSolution EOS label + self.aqueous_species_string, self.speciation_ratio_mol_kg, self.ocean_solid_phases, self.EOS_lookup_label = SpeciesParser(self.comp, self.w_ppt) + Timing.setTime(time.time()) + EOSLookupTable = EOSLookupTableLoader(self.aqueous_species_string, self.speciation_ratio_mol_kg, self.ocean_solid_phases, self.EOS_lookup_label) + Timing.logTime('EOSLookupTableLoader()', time.time()) + self.type = 'Reaktoro' + Timing.setTime(time.time()) + PropsP_MPa, PropsT_K, rho_kgm3, Cp_JkgK, alpha_pK, kTherm_WmK, self.EOSdeltaP, self.EOSdeltaT = ( + RktProps(EOSLookupTable, PropsP_MPa, PropsT_K, self.EXTRAP)) + Timing.logTime('RktProps()', time.time()) + # Reassign P and T of phase to match new inputs from RktProps + _, _, Pphase_MPa, Tphase_K = ReAssignPT(P_MPa, T_K, PropsP_MPa[0], PropsP_MPa[-1], PropsT_K[0], PropsT_K[-1], MELT=MELT, propsStepReductionFactor=propsStepReductionFactor) + Timing.setTime(time.time()) + self.ufn_Seismic = RktSeismic(EOSLookupTable, self.EXTRAP) + Timing.logTime('RktSeismic()', time.time()) + Timing.setTime(time.time()) + self.ufn_phase = RktPhaseLookup(EOSLookupTable, Pphase_MPa, Tphase_K) + Timing.logTime('RktPhaseLookup()', time.time()) + Timing.setTime(time.time()) + self.ufn_species = RktHydroSpecies(self.aqueous_species_string, self.speciation_ratio_mol_kg, self.ocean_solid_phases) + Timing.logTime('RktHydroSpecies()', time.time()) + #self.ufn_rxn_affinity = RktRxnAffinity(self.aqueous_species_string, self.speciation_ratio_mol_kg, self.ocean_solid_phases) Incorporated in self.ufn_species now + Timing.setTime(time.time()) + if sigmaFixed_Sm is not None or wOcean_ppt == 0: + # If wOcean_ppt == 0, we are in pure water mode and should use the default conductivity of pure water + self.ufn_sigma_Sm = H2Osigma_Sm(sigmaFixed_Sm) + else: + # ions = {'Na_p1': {'mols': 0.1}, 'Cl_m1': {'mols': 0.1}} + # self.ufn_sigma_Sm = elecCondMcCleskey2012(T_K,ions) # see McCleskeyFig1 benchmark for example usage. this is a placeholder that doesn't have the inputs set up correctly. Has no pressure dependence currently + self.ufn_sigma_Sm = RktConduct(self.aqueous_species_string, self.speciation_ratio_mol_kg, self.ocean_solid_phases, self.ufn_species) + self.propsPmax = self.Pmax else: - # ions = {'Na_p1': {'mols': 0.1}, 'Cl_m1': {'mols': 0.1}} - # self.ufn_sigma_Sm = elecCondMcCleskey2012(T_K,ions) # see McCleskeyFig1 benchmark for example usage. this is a placeholder that doesn't have the inputs set up correctly. Has no pressure dependence currently - self.ufn_sigma_Sm = RktConduct(self.aqueous_species_string, self.speciation_ratio_mol_kg, self.ocean_solid_phases, self.ufn_species) - self.propsPmax = self.Pmax - else: - raise ValueError(f'Unable to load ocean EOS. self.comp="{self.comp}" but options are "Seawater", "NH3", "MgSO4", ' + - '"NaCl", "CustomSolution", and "none" (for waterless bodies).') - - self.ufn_rho_kgm3 = RectBivariateSpline(P_MPa, T_K, rho_kgm3) - self.ufn_Cp_JkgK = RectBivariateSpline(P_MPa, T_K, Cp_JkgK) - self.ufn_alpha_pK = RectBivariateSpline(P_MPa, T_K, alpha_pK) - self.ufn_kTherm_WmK = RectBivariateSpline(P_MPa, T_K, kTherm_WmK) - self.ufn_eta_Pas = ViscOceanUniform_Pas(etaSet_Pas=etaFixed_Pas, comp=compstr) - + raise ValueError(f'Unable to load ocean EOS. self.comp="{self.comp}" but options are "Seawater", "NH3", "MgSO4", ' + + '"NaCl", "CustomSolution", and "none" (for waterless bodies).') + Timing.setTime(time.time()) + kTherm_WmK = np.zeros_like(alpha_pK) + kThermConst_WmK # Placeholder until we implement a self-consistent calculation - should be kept for non self consistent modeling + self.ufn_rho_kgm3 = RectBivariateSpline(PropsP_MPa, PropsT_K, rho_kgm3) + self.ufn_Cp_JkgK = RectBivariateSpline(PropsP_MPa, PropsT_K, Cp_JkgK) + self.ufn_alpha_pK = RectBivariateSpline(PropsP_MPa, PropsT_K, alpha_pK) + self.ufn_kTherm_WmK = RectBivariateSpline(PropsP_MPa, PropsT_K, kTherm_WmK) + self.ufn_eta_Pas = ViscOceanUniform_Pas(etaSet_Pas=etaFixed_Pas, comp=compstr) + Timing.logTime('RectBivariateSpline for OceanEOSStruct()', time.time()) # Include placeholder to overlap infrastructure with other EOS classes self.fn_porosCorrect = None - # Store complete EOSStruct in global list of loaded EOSs, - # but only if we weren't forcing a recalculation. This allows + # Store complete EOSStruct in global list of loaded EOSs. This allows # us to use a finer step in getting the ice shell thickness while # not slowing down ocean calculations. - if not FORCE_NEW: - EOSlist.loaded[self.EOSlabel] = self - EOSlist.ranges[self.EOSlabel] = self.rangeLabel + EOSlist.loaded[self.EOSlabel] = self + EOSlist.ranges[self.EOSlabel] = self.rangeLabel + Timing.logTime('OceanEOSStruct()', time.time()) # Limit extrapolation to use nearest value from evaluated fit def fn_phase(self, P_MPa, T_K, grid=False): @@ -322,47 +378,33 @@ def fn_eta_Pas(self, P_MPa, T_K, grid=False): if not self.EXTRAP: P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) return self.ufn_eta_Pas(P_MPa, T_K, grid=grid) - def fn_species(self, P_MPa, T_K, grid = False): + def fn_species(self, P_MPa, T_K, grid = False, reactionSubstruct = None): """ Returns speciation at provided P_MPa and T_K """ if not self.EXTRAP: P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) - return self.ufn_species(P_MPa, T_K, grid=grid) - def fn_rxn_affinity(self, P_MPa, T_K, reaction, concentrations, grid = False): - """ - Calculates the affinity of a reaction whose species are at prescribed concentrations at disequilibrium - - ONLY APPLICABLE TO CUSTOMSOLUTION FOR NOW - """ - if not self.EXTRAP: - P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) - return self.ufn_rxn_affinity(P_MPa, T_K, reaction, concentrations, grid=grid) + return self.ufn_species(P_MPa, T_K, grid=grid, reactionSubstruct=reactionSubstruct) def GetIceEOS(P_MPa, T_K, phaseStr, porosType=None, phiTop_frac=0, Pclosure_MPa=0, phiMin_frac=0, EXTRAP=False, ClathDissoc=None, minPres_MPa=None, minTres_K=None, - ICEIh_DIFFERENT=False, etaFixed_Pas=None, TviscTrans_K=None, mixParameters=None): + ICEIh_DIFFERENT=False, etaFixed_Pas=None, TviscTrans_K=None, mixParameters=None, doConstantProps = False, constantProperties = None, kThermConst_WmK=None): # Check if this is a mixed EOS - try: - # Check if MixedPhaseSeparator can process this phaseStr (will raise error if not mixed) - phaseOne, phaseTwo = MixedPhaseSeparator(phaseStr) - except Exception: - # MixedPhaseSeparator raised an error, so this is not a mixed EOS - create normal ice EOS - iceEOS = IceEOSStruct(P_MPa, T_K, phaseStr, porosType=porosType, phiTop_frac=phiTop_frac, - Pclosure_MPa=Pclosure_MPa, phiMin_frac=phiMin_frac, EXTRAP=EXTRAP, - ClathDissoc=ClathDissoc, minPres_MPa=minPres_MPa, minTres_K=minTres_K, - ICEIh_DIFFERENT=ICEIh_DIFFERENT, etaFixed_Pas=etaFixed_Pas, - TviscTrans_K=TviscTrans_K) - else: - # If no error is thrown, it's a mixed phase - create mixed EOS (allow MixedEOSStruct errors to propagate) + if 'mixed' in phaseStr.lower(): iceEOS = MixedEOSStruct(P_MPa, T_K, phaseStr, mixParameters, porosType=porosType, phiTop_frac=phiTop_frac, Pclosure_MPa=Pclosure_MPa, phiMin_frac=phiMin_frac, EXTRAP=EXTRAP, ClathDissoc=ClathDissoc, minPres_MPa=minPres_MPa, minTres_K=minTres_K, ICEIh_DIFFERENT=ICEIh_DIFFERENT, etaFixed_Pas=etaFixed_Pas, - TviscTrans_K=TviscTrans_K) + TviscTrans_K=TviscTrans_K, kThermConst_WmK=kThermConst_WmK, doConstantProps = doConstantProps, constantProperties = constantProperties) + else: + iceEOS = IceEOSStruct(P_MPa, T_K, phaseStr, porosType=porosType, phiTop_frac=phiTop_frac, + Pclosure_MPa=Pclosure_MPa, phiMin_frac=phiMin_frac, EXTRAP=EXTRAP, + ClathDissoc=ClathDissoc, minPres_MPa=minPres_MPa, minTres_K=minTres_K, + ICEIh_DIFFERENT=ICEIh_DIFFERENT, etaFixed_Pas=etaFixed_Pas, + TviscTrans_K=TviscTrans_K, kThermConst_WmK=kThermConst_WmK, doConstantProps = doConstantProps, constantProperties = constantProperties) if iceEOS.ALREADY_LOADED: log.debug(f'Ice {phaseStr} EOS already loaded. Reusing existing EOS.') iceEOS = EOSlist.loaded[iceEOS.EOSlabel] @@ -379,10 +421,11 @@ def GetIceEOS(P_MPa, T_K, phaseStr, porosType=None, phiTop_frac=0, Pclosure_MPa= class IceEOSStruct: def __init__(self, P_MPa, T_K, phaseStr, porosType=None, phiTop_frac=0, Pclosure_MPa=0, phiMin_frac=0, EXTRAP=False, ClathDissoc=None, minPres_MPa=None, minTres_K=None, - ICEIh_DIFFERENT=False, etaFixed_Pas=None, TviscTrans_K=None): - self.EOSlabel = f'phase{phaseStr}poros{porosType}phi{phiTop_frac}Pclose{Pclosure_MPa}' + \ - f'phiMin{phiMin_frac}extrap{EXTRAP}etaFixed{etaFixed_Pas}' + \ - f'TviscTrans{TviscTrans_K}' + ICEIh_DIFFERENT=False, etaFixed_Pas=None, TviscTrans_K=None, kThermConst_WmK=None, doConstantProps = False, constantProperties = None): + + self.EOSlabel = GetIceEOSLabel(phaseStr, porosType, phiTop_frac, Pclosure_MPa, phiMin_frac, EXTRAP, etaFixed_Pas, TviscTrans_K) + if doConstantProps: + self.EOSlabel += f'constantProperties{constantProperties}' self.ALREADY_LOADED, self.rangeLabel, P_MPa, T_K, self.deltaP, self.deltaT \ = CheckIfEOSLoaded(self.EOSlabel, P_MPa, T_K, minPres_MPa=minPres_MPa, minTres_K=minTres_K) self.Pmin = np.min(P_MPa) @@ -415,104 +458,127 @@ def __init__(self, P_MPa, T_K, phaseStr, porosType=None, phiTop_frac=0, Pclosure # Assign phase ID and string for convenience in functions where iceEOS is passed self.phaseStr = phaseStr self.phaseID = PhaseInv(phaseStr) - - if phaseStr == 'Clath': - # Special functions for clathrate properties - rho_kgm3, Cp_JkgK, alpha_pK, kTherm_WmK \ - = ClathProps(P_MPa, T_K) - if ClathDissoc is not None and ClathDissoc.NAGASHIMA: - self.phase = ClathStableNagashima2017(P_MPa, T_K) + + if doConstantProps: + self.Tconv_K = constantProperties['Tconv_K'] if 'Tconv_K' in constantProperties else None # Defines switching point for constant properties + if self.Tconv_K is None: + self.ufn_rho_kgm3 = returnVal(constantProperties['rho_kgm3']) + self.ufn_Cp_JkgK = returnVal(constantProperties['Cp_JkgK']) + self.ufn_alpha_pK = returnVal(constantProperties['alpha_pK']) + self.ufn_kTherm_WmK = returnVal(constantProperties['kTherm_WmK']) + self.ufn_Seismic = ReturnMultipleVal((constantProperties['VP_GPa'], constantProperties['VS_GPa'], constantProperties['KS_GPa'], constantProperties['GS_GPa'])) + self.ufn_sigma_Sm = returnVal(constantProperties['sigma_Sm']) + self.ufn_eta_Pas = returnVal(constantProperties['eta_Pas']) else: - self.phase = ClathStableSloan1998(P_MPa, T_K) - - # Create phase finder -- note that the results from this function must be cast to int after retrieval - # Returns either Constants.phaseClath (stable) or 0 (not stable), making it compatible with GetTfreeze - self.ufn_phase = RGIwrap(RegularGridInterpolator((P_MPa, T_K), self.phase, method='nearest'), - self.deltaP, self.deltaT) - self.ufn_Seismic = ClathSeismic() + self.ufn_rho_kgm3 = returnValWithThreshold(constantProperties['rho_kgm3'][0], constantProperties['rho_kgm3'][1], self.Tconv_K) + self.ufn_Cp_JkgK = returnValWithThreshold(constantProperties['Cp_JkgK'][0], constantProperties['Cp_JkgK'][1], self.Tconv_K) + self.ufn_alpha_pK = returnValWithThreshold(constantProperties['alpha_pK'][0], constantProperties['alpha_pK'][1], self.Tconv_K) + self.ufn_kTherm_WmK = returnValWithThreshold(constantProperties['kTherm_WmK'][0], constantProperties['kTherm_WmK'][1], self.Tconv_K) + self.ufn_Seismic = ReturnMultipleValWithThreshold((constantProperties['VP_GPa'][0], constantProperties['VS_GPa'][0], constantProperties['KS_GPa'][0], constantProperties['GS_GPa'][0]), + (constantProperties['VP_GPa'][1], constantProperties['VS_GPa'][1], constantProperties['KS_GPa'][1], constantProperties['GS_GPa'][1]), + self.Tconv_K) + self.ufn_sigma_Sm = returnValWithThreshold(constantProperties['sigma_Sm'][0], constantProperties['sigma_Sm'][1], self.Tconv_K) + self.ufn_eta_Pas = returnValWithThreshold(constantProperties['eta_Pas'][0], constantProperties['eta_Pas'][1], self.Tconv_K) else: - # Get tabular data from SeaFreeze for all other ice phases - # Set extrapolation boundaries to limits defined in SeaFreeze - Pmin = {'Ih': 0, 'II': 0, 'III': 0, 'V': 0, 'VI': 0} - Pmax = {'Ih': 400, 'II': 900, 'III': 500, 'V': 1000, 'VI': 3000} - Tmin = {'Ih': 1.0, 'II': 0, 'III': 0, 'V': 0, 'VI': 0} - Tmax = {'Ih': 300.99999999999983, 'II': 270.00000000000006, 'III': 270.00000000000006, - 'V': 300.0000000000001, 'VI': 400.0000000000001} - self.Pmin = np.maximum(self.Pmin, Pmin[self.phaseStr]) - self.Pmax = np.minimum(self.Pmax, Pmax[self.phaseStr]) - self.Tmin = np.maximum(self.Tmin, Tmin[self.phaseStr]) - self.Tmax = np.minimum(self.Tmax, Tmax[self.phaseStr]) - if np.min(P_MPa) < self.Pmin: - log.warning(f'Input Pmin less than SeaFreeze limit for ice {self.phaseStr}. Resetting to SF min of' - f' {self.Pmin} MPa.') - P_MPa = np.linspace(self.Pmin, np.max(P_MPa), np.size(P_MPa)) - if np.min(P_MPa) > self.Pmax: - # Sometimes when querying for HP ices, the pressure input does not make sense for the given ice and sfz returns np.nan for this phase. - # In this case, we should reset np.min(P_MPa) to just slightly below Pmax so we do not get np.nan returned - log.warning(f'Input Pmin is greater than the SeaFreeze limit for ice {self.phaseStr}. Resetting to SF max of' - f' {self.Pmax} MPa.') - P_MPa = np.linspace(self.Pmax*0.99999, np.max(P_MPa), np.size(P_MPa)) - if np.max(P_MPa) > self.Pmax: - log.warning(f'Input Pmax greater than SeaFreeze limit for ice {self.phaseStr}. Resetting to SF ' - f'max of {self.Pmax} MPa.') - P_MPa = np.linspace(np.min(P_MPa), self.Pmax, np.size(P_MPa)) - if np.min(T_K) < self.Tmin: - log.warning(f'Input Tmin less than SeaFreeze limit for ice {self.phaseStr}. Resetting to SF min of' - f' {self.Tmin} K.') - T_K = np.linspace(self.Tmin, np.max(T_K), np.size(T_K)) - - if np.max(T_K) > self.Tmax: - log.warning(f'Input Tmax greater than SeaFreeze limit for ice {self.phaseStr}. Resetting to SF ' - f'max of' - f' {self.Tmax} K.') - T_K = np.linspace(np.min(T_K), self.Tmax, np.size(T_K)) - if (T_K[-1] - T_K[0]) < self.EOSdeltaT: - # Sometimes when querying for HP ices, we reset the input arrays below the EOS deltas specified by user. In this case, we should reset to the EOS delta value - T_K = np.linspace(T_K[0] - self.EOSdeltaT, T_K[-1], 4) - self.Tmin = np.min(T_K) - if (P_MPa[-1] - P_MPa[0]) < self.EOSdeltaP: - # Sometimes when querying for HP ices, we reset the input arrays below the EOS deltas specified by user. In this case, we should reset to the EOS delta value - P_MPa = np.linspace(P_MPa[0] - self.EOSdeltaT, P_MPa[-1], 4) - self.Pmin = np.min(P_MPa) - log.warning(f'Input T_K or P_MPa range is less than SeaFreeze limit for ice {self.phaseStr}. Resetting to SF min of' - f' {self.Tmin} K and SF max of {self.Pmax} MPa.') - T_K = np.linspace(self.Tmin, self.Tmax, np.size(T_K)) - P_MPa = np.linspace(self.Pmin, self.Pmax, np.size(P_MPa)) - if (T_K[0] >= T_K[-1]) or (P_MPa[0] >= P_MPa[-1]): - # Sometimes when querying for HP ices, the temperature or pressure reset makes the array no longer strictly increasing and rectbivariatespline requires strictly increasing inputs. - # In this case, we are outside the bounds of plausible range of this HP ice forming, so let's set all to np.nan - T_K = np.linspace(np.min(T_K)*0.9999, np.max(T_K), np.size(T_K)) - P_MPa = np.linspace(np.min(P_MPa)*0.9999, np.max(P_MPa), np.size(P_MPa)) - rho_kgm3 = np.zeros((np.size(P_MPa), np.size(T_K))) + np.nan - Cp_JkgK = rho_kgm3 - alpha_pK = rho_kgm3 - kTherm_WmK = rho_kgm3 + if phaseStr == 'Clath': + # Special functions for clathrate properties + rho_kgm3, Cp_JkgK, alpha_pK, kTherm_WmK \ + = ClathProps(P_MPa, T_K) + if ClathDissoc is not None and ClathDissoc.NAGASHIMA: + self.phase = ClathStableNagashima2017(P_MPa, T_K) + else: + self.phase = ClathStableSloan1998(P_MPa, T_K) + + # Create phase finder -- note that the results from this function must be cast to int after retrieval + # Returns either Constants.phaseClath (stable) or 0 (not stable), making it compatible with GetTfreeze + self.ufn_phase = PhaseInterpolator(P_MPa, T_K, self.phase) + self.ufn_Seismic = ClathSeismic() else: - PTgrid = sfPTgrid(P_MPa, T_K) - iceOut = SeaFreeze(PTgrid, phaseStr) - rho_kgm3 = iceOut.rho - Cp_JkgK = iceOut.Cp - alpha_pK = iceOut.alpha - if ICEIh_DIFFERENT and phaseStr == 'Ih': - kTherm_WmK = np.array([kThermIceIhWolfenbarger2021(T_K) for _ in P_MPa]) + # Get tabular data from SeaFreeze for all other ice phases + # Set extrapolation boundaries to limits defined in SeaFreeze + Pmin = {'Ih': 0, 'II': 0, 'III': 0, 'V': 0, 'VI': 0} + Pmax = {'Ih': 400, 'II': 900, 'III': 500, 'V': 1000, 'VI': 3000} + Tmin = {'Ih': 1.0, 'II': 0, 'III': 0, 'V': 0, 'VI': 0} + Tmax = {'Ih': 300.99999999999983, 'II': 270.00000000000006, 'III': 270.00000000000006, + 'V': 300.0000000000001, 'VI': 400.0000000000001} + self.Pmin = np.maximum(self.Pmin, Pmin[self.phaseStr]) + self.Pmax = np.minimum(self.Pmax, Pmax[self.phaseStr]) + self.Tmin = np.maximum(self.Tmin, Tmin[self.phaseStr]) + self.Tmax = np.minimum(self.Tmax, Tmax[self.phaseStr]) + if np.min(P_MPa) < self.Pmin: + log.warning(f'Input Pmin less than SeaFreeze limit for ice {self.phaseStr}. Resetting to SF min of' + f' {self.Pmin} MPa.') + P_MPa = np.linspace(self.Pmin, np.max(P_MPa), np.size(P_MPa)) + if np.min(P_MPa) > self.Pmax: + # Sometimes when querying for HP ices, the pressure input does not make sense for the given ice and sfz returns np.nan for this phase. + # In this case, we should reset np.min(P_MPa) to just slightly below Pmax so we do not get np.nan returned + log.warning(f'Input Pmin is greater than the SeaFreeze limit for ice {self.phaseStr}. Resetting to SF max of' + f' {self.Pmax} MPa.') + P_MPa = np.linspace(self.Pmax*0.99999, np.max(P_MPa), np.size(P_MPa)) + if np.max(P_MPa) > self.Pmax: + log.warning(f'Input Pmax greater than SeaFreeze limit for ice {self.phaseStr}. Resetting to SF ' + f'max of {self.Pmax} MPa.') + P_MPa = np.linspace(np.min(P_MPa), self.Pmax, np.size(P_MPa)) + if np.min(T_K) < self.Tmin: + log.warning(f'Input Tmin less than SeaFreeze limit for ice {self.phaseStr}. Resetting to SF min of' + f' {self.Tmin} K.') + T_K = np.linspace(self.Tmin, np.max(T_K), np.size(T_K)) + + if np.max(T_K) > self.Tmax: + log.warning(f'Input Tmax greater than SeaFreeze limit for ice {self.phaseStr}. Resetting to SF ' + f'max of' + f' {self.Tmax} K.') + T_K = np.linspace(np.min(T_K), self.Tmax, np.size(T_K)) + if (T_K[-1] - T_K[0]) < self.EOSdeltaT: + # Sometimes when querying for HP ices, we reset the input arrays below the EOS deltas specified by user. In this case, we should reset to the EOS delta value + T_K = np.linspace(T_K[0] - self.EOSdeltaT, T_K[-1], 4) + self.Tmin = np.min(T_K) + if (P_MPa[-1] - P_MPa[0]) < self.EOSdeltaP: + # Sometimes when querying for HP ices, we reset the input arrays below the EOS deltas specified by user. In this case, we should reset to the EOS delta value + P_MPa = np.linspace(P_MPa[0] - self.EOSdeltaT, P_MPa[-1], 4) + self.Pmin = np.min(P_MPa) + log.warning(f'Input T_K or P_MPa range is less than SeaFreeze limit for ice {self.phaseStr}. Resetting to SF min of' + f' {self.Tmin} K and SF max of {self.Pmax} MPa.') + T_K = np.linspace(self.Tmin, self.Tmax, np.size(T_K)) + P_MPa = np.linspace(self.Pmin, self.Pmax, np.size(P_MPa)) + if (T_K[0] >= T_K[-1]) or (P_MPa[0] >= P_MPa[-1]): + # Sometimes when querying for HP ices, the temperature or pressure reset makes the array no longer strictly increasing and rectbivariatespline requires strictly increasing inputs. + # In this case, we are outside the bounds of plausible range of this HP ice forming, so let's set all to np.nan + T_K = np.linspace(np.min(T_K)*0.9999, np.max(T_K), np.size(T_K)) + P_MPa = np.linspace(np.min(P_MPa)*0.9999, np.max(P_MPa), np.size(P_MPa)) + rho_kgm3 = np.zeros((np.size(P_MPa), np.size(T_K))) + np.nan + Cp_JkgK = rho_kgm3 + alpha_pK = rho_kgm3 + kTherm_WmK = rho_kgm3 else: - kTherm_WmK = np.array([kThermIsobaricAnderssonInaba2005(T_K, PhaseInv(phaseStr)) for _ in P_MPa]) - self.ufn_Seismic = IceSeismic(phaseStr, self.EXTRAP) - self.ufn_phase = returnVal(self.phaseID) - # Interpolate functions for this ice phase that can be queried for properties - self.ufn_rho_kgm3 = RectBivariateSpline(P_MPa, T_K, rho_kgm3) - self.ufn_Cp_JkgK = RectBivariateSpline(P_MPa, T_K, Cp_JkgK) - self.ufn_alpha_pK = RectBivariateSpline(P_MPa, T_K, alpha_pK) - self.ufn_kTherm_WmK = RectBivariateSpline(P_MPa, T_K, kTherm_WmK) - self.ufn_eta_Pas = ViscIceUniform_Pas(etaSet_Pas=etaFixed_Pas, TviscTrans_K=TviscTrans_K) + PTgrid = sfPTgrid(P_MPa, T_K) + iceOut, P_MPa, T_K = IceSeaFreezeProps(PTgrid, phaseStr) + rho_kgm3 = iceOut.rho + Cp_JkgK = iceOut.Cp + alpha_pK = iceOut.alpha + if kThermConst_WmK is not None and kThermConst_WmK[phaseStr] is not None: + kTherm_WmK = np.zeros((np.size(P_MPa), np.size(T_K))) + kThermConst_WmK[phaseStr] + else: + if ICEIh_DIFFERENT and phaseStr == 'Ih': + kTherm_WmK = np.array([kThermIceIhWolfenbarger2021(T_K) for _ in P_MPa]) + else: + kTherm_WmK = np.array([kThermIsobaricAnderssonInaba2005(T_K, PhaseInv(phaseStr)) for _ in P_MPa]) + self.ufn_Seismic = IceSeismic(phaseStr, self.EXTRAP) + self.ufn_phase = returnVal(self.phaseID) + # Interpolate functions for this ice phase that can be queried for properties + self.ufn_rho_kgm3 = RectBivariateSpline(P_MPa, T_K, rho_kgm3) + self.ufn_Cp_JkgK = RectBivariateSpline(P_MPa, T_K, Cp_JkgK) + self.ufn_alpha_pK = RectBivariateSpline(P_MPa, T_K, alpha_pK) + self.ufn_kTherm_WmK = RectBivariateSpline(P_MPa, T_K, kTherm_WmK) + self.ufn_eta_Pas = ViscIceUniform_Pas(etaSet_Pas=etaFixed_Pas, TviscTrans_K=TviscTrans_K) if porosType is None or porosType == 'none': self.ufn_phi_frac = ReturnZeros(1) self.POROUS = False else: self.ufn_phi_frac = GetphiCalc(phiTop_frac, - GetphiFunc(porosType, phiTop_frac, Pclosure_MPa, None, P_MPa, T_K), - phiMin_frac) + GetphiFunc(porosType, phiTop_frac, Pclosure_MPa, None, P_MPa, T_K), + phiMin_frac) self.POROUS = True # Store complete EOSStruct in global list of loaded EOSs @@ -561,6 +627,8 @@ def fn_eta_Pas(self, P_MPa, T_K, grid=False): if not self.EXTRAP: P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) return self.ufn_eta_Pas(P_MPa, T_K, grid=grid) + def updateConvectionViscosity(self, etaConv_Pas, Tconv_K): + self.ufn_eta_Pas.updateConvectionViscosity(etaConv_Pas, Tconv_K) class MixedEOSStruct: @@ -571,7 +639,7 @@ class MixedEOSStruct: def __init__(self, P_MPa, T_K, phaseStr, mixParameters, porosType=None, phiTop_frac=0, Pclosure_MPa=0, phiMin_frac=0, EXTRAP=False, ClathDissoc=None, minPres_MPa=None, minTres_K=None, ICEIh_DIFFERENT=False, - etaFixed_Pas=None, TviscTrans_K=None): + etaFixed_Pas=None, TviscTrans_K=None, kThermConst_WmK=None, doConstantProps = False, constantProperties = None): # Get the name of each EOS self.mixFrac = mixParameters['mixFrac'] self.JmixedRheologyConstant = mixParameters['JmixedRheologyConstant'] @@ -586,13 +654,19 @@ def __init__(self, P_MPa, T_K, phaseStr, mixParameters, porosType=None, phiTop_f self.phaseStr = phaseStr self.phaseID = PhaseInv(phaseStr) self.phaseOne, self.phaseTwo = MixedPhaseSeparator(phaseStr) + if 'Clath' in self.phaseOne or 'Clath' in self.phaseTwo: + self.mixType = 'Clathrate' + else: + # Implement new mix types as they are needed - important for defining fn_phase + self.mixType = 'None' + # Create the component EOS structures self.firstEOS = IceEOSStruct(P_MPa, T_K, self.phaseOne, porosType=porosType, phiTop_frac=phiTop_frac, Pclosure_MPa=Pclosure_MPa, phiMin_frac=phiMin_frac, EXTRAP=EXTRAP, ClathDissoc=ClathDissoc, minPres_MPa=minPres_MPa, minTres_K=minTres_K, ICEIh_DIFFERENT=ICEIh_DIFFERENT, - etaFixed_Pas=etaFixed_Pas, TviscTrans_K=TviscTrans_K) + etaFixed_Pas=etaFixed_Pas, TviscTrans_K=TviscTrans_K, kThermConst_WmK=kThermConst_WmK, doConstantProps = doConstantProps, constantProperties = constantProperties) if self.firstEOS.ALREADY_LOADED: log.debug(f'Ice {self.phaseOne} EOS already loaded. Reusing existing EOS.') self.firstEOS = EOSlist.loaded[self.firstEOS.EOSlabel] @@ -602,7 +676,7 @@ def __init__(self, P_MPa, T_K, phaseStr, mixParameters, porosType=None, phiTop_f phiMin_frac=phiMin_frac, EXTRAP=EXTRAP, ClathDissoc=ClathDissoc, minPres_MPa=minPres_MPa, minTres_K=minTres_K, ICEIh_DIFFERENT=ICEIh_DIFFERENT, - etaFixed_Pas=etaFixed_Pas, TviscTrans_K=TviscTrans_K) + etaFixed_Pas=etaFixed_Pas, TviscTrans_K=TviscTrans_K, kThermConst_WmK=kThermConst_WmK, doConstantProps = doConstantProps, constantProperties = constantProperties) if self.secondEOS.ALREADY_LOADED: log.debug(f'Ice {self.phaseTwo} EOS already loaded. Reusing existing EOS.') self.secondEOS = EOSlist.loaded[self.secondEOS.EOSlabel] @@ -663,16 +737,26 @@ def _mixingRule(self, prop1, prop2, rule): def fn_phase(self, P_MPa, T_K, grid=False): """ For mixed phases, we call the phase function from both component EOS and add them together. This takes advantage of the two facts: 1) that the phaseID of the mixed phase is the sum of the phaseIDs of the component phases. - 2) When one phase becomes unstable, the fn_phase returns zero for the unstable phase and the phaseID of the mixed phase will be the phaseID of the only stable phase.""" + 2) For ice EOS, the returned phaseID is only the ice phaseID, since we use the ocean fn phase for ice phase stability. This means we should just return the phaseID of the clathrate phase if it is present. + """ if not self.EXTRAP: P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) #TODO Fix this implementation so that it uses oceanEOS.fn_phase phase1 = self.firstEOS.fn_phase(P_MPa, T_K, grid) phase2 = self.secondEOS.fn_phase(P_MPa, T_K, grid) - if phase1 == 0: - return phase1 + if self.mixType == 'Clathrate': + if self.firstEOS.phaseID == Constants.phaseClath: + # Replace all zeros in phase1 with the negative of phaseID of secondEOS so that when we add it, we get the phaseID of the mixed clathrate phase or zero if clathrate is unstable + phase1 = np.where(phase1 == 0, -self.secondEOS.phaseID, phase1) + return phase1 + phase2 # Add the phaseIDs of the component phases to get the phaseID of the mixed clathrate phase + else: + # Replace all zeros in phase2 with the negative of phaseID of firstEOS so that when we add it, we get the phaseID of the mixed clathrate phase or zero if clathrate is unstable + phase2 = np.where(phase2 == 0, -self.firstEOS.phaseID, phase2) + return phase1 + phase2 # Add the phaseIDs of the component phases to get the phaseID of the mixed clathrate phase else: + # Implement new mix types as they are needed - important for defining fn_phase return phase1 + phase2 + def fn_rho_kgm3(self, P_MPa, T_K, grid=False): if not self.EXTRAP: @@ -730,6 +814,10 @@ def fn_eta_Pas(self, P_MPa, T_K, grid=False): eta2 = self.secondEOS.fn_eta_Pas(P_MPa, T_K, grid) return self._mixingRule(eta1, eta2, 'Carahan2004Averaging') + def updateConvectionViscosity(self, etaConv_Pas, Tconv_K): + self.firstEOS.updateConvectionViscosity(etaConv_Pas, Tconv_K) + self.secondEOS.updateConvectionViscosity(etaConv_Pas, Tconv_K) + def fn_averageValuesAccordingtoRule(self, prop1, prop2, rule): if rule == 'arithmetic': return self._mixingRule(prop1, prop2, 'arithmetic') @@ -739,13 +827,136 @@ def fn_averageValuesAccordingtoRule(self, prop1, prop2, rule): return self._mixingRule(prop1, prop2, 'Carahan2004Averaging') + +class ConstantEOSStruct: + def __init__(self, constantProperties, EOStype = None): + if EOStype == 'inner': + self.EOStype = 'inner' + self.EOSlabel = f'constant{EOStype}constantProps{constantProperties}' + elif EOStype == 'ocean': + self.EOStype = 'ocean' + self.EOSlabel = f'constant{EOStype}constantProps{constantProperties}' + else: + raise ValueError(f'Invalid EOStype: {EOStype}') + self.EOSlabel = f'constant{EOStype}constantProps{constantProperties}' + if self.EOSlabel in EOSlist.loaded.keys(): + self.ALREADY_LOADED = True + else: + self.ALREADY_LOADED = False + + if not self.ALREADY_LOADED: + self.EOStype = EOStype + self.constantProperties = constantProperties + self.ufn_rho_kgm3 = returnVal(constantProperties['rho_kgm3']) + self.ufn_Cp_JkgK = returnVal(constantProperties['Cp_JkgK']) + self.ufn_alpha_pK = returnVal(constantProperties['alpha_pK']) + self.ufn_kTherm_WmK = returnVal(constantProperties['kTherm_WmK']) + self.ufn_Seismic = ReturnMultipleVal((constantProperties['VP_kms'], constantProperties['VS_kms'], constantProperties['KS_GPa'], constantProperties['GS_GPa'])) + self.ufn_sigma_Sm = returnVal(constantProperties['sigma_Sm']) + self.ufn_eta_Pas = returnVal(constantProperties['eta_Pas']) + self.EOSdeltaP = None + self.EOSdeltaT = None + self.propsPmax = 0 + + + + def fn_porosCorrect(self, propBulk, propPore, phi, J): + # Combine pore fluid properties with matrix properties in accordance with + # Yu et al. (2016): http://dx.doi.org/10.1016/j.jrmge.2015.07.004 + return (propBulk**J * (1 - phi) + propPore**J * phi) ** (1/J) + def fn_phase(self, P_MPa, T_K, grid=False): + if not self.EXTRAP: + P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) + return self.ufn_phase(P_MPa, T_K, grid=grid) + def fn_rho_kgm3(self, P_MPa, T_K, grid=False): + # Limit extrapolation to use nearest value from evaluated fit if desired + if not self.EXTRAP: + P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) + return self.ufn_rho_kgm3(P_MPa, T_K, grid=grid) + def fn_Cp_JkgK(self, P_MPa, T_K, grid=False): + if not self.EXTRAP: + P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) + return self.ufn_Cp_JkgK(P_MPa, T_K, grid=grid) + def fn_alpha_pK(self, P_MPa, T_K, grid=False): + if not self.EXTRAP: + P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) + return self.ufn_alpha_pK(P_MPa, T_K, grid=grid) + def fn_kTherm_WmK(self, P_MPa, T_K, grid=False): + if not self.EXTRAP: + P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) + return self.ufn_kTherm_WmK(P_MPa, T_K, grid=grid) + def fn_VP_kms(self, P_MPa, T_K, grid=False): + if not self.EXTRAP: + P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) + return self.ufn_VP_kms(P_MPa, T_K, grid=grid) + def fn_VS_kms(self, P_MPa, T_K, grid=False): + if not self.EXTRAP: + P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) + return self.ufn_VS_kms(P_MPa, T_K, grid=grid) + def fn_KS_GPa(self, P_MPa, T_K, grid=False): + if not self.EXTRAP: + P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) + return self.ufn_KS_GPa(P_MPa, T_K, grid=grid) + def fn_GS_GPa(self, P_MPa, T_K, grid=False): + if not self.EXTRAP: + P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) + return self.ufn_GS_GPa(P_MPa, T_K, grid=grid) + def fn_phi_frac(self, P_MPa, T_K, grid=False): + if not self.EXTRAP: + P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) + return self.ufn_phi_frac(P_MPa, T_K, grid=grid) + def fn_eta_Pas(self, P_MPa, T_K, grid=False): + if not self.EXTRAP: + P_MPa, T_K = ResetNearestExtrap(P_MPa, T_K, self.Pmin, self.Pmax, self.Tmin, self.Tmax) + return self.ufn_eta_Pas(P_MPa, T_K, grid=grid) + + + + class returnVal: def __init__(self, val): self.val = val def __call__(self, P, T, grid=False): if grid: P, _ = np.meshgrid(P, T, indexing='ij') - return (np.ones_like(P) * self.val).astype(np.int_) + return (np.ones_like(P) * self.val) + +class returnValWithThreshold: + def __init__(self, val_below, val_above, threshold_K): + self.val_below = val_below + self.val_above = val_above + self.threshold_K = threshold_K + + def __call__(self, P, T, grid=False): + if grid: + P, T_grid = np.meshgrid(P, T, indexing='ij') + result = np.where(T_grid < self.threshold_K, self.val_below, self.val_above) + else: + result = np.where(T < self.threshold_K, self.val_below, self.val_above) + return result + +class ReturnMultipleVal: + def __init__(self, vals): + self.vals = vals + def __call__(self, P, T, grid=False): + if grid: + P, _ = np.meshgrid(P, T, indexing='ij') + # Return a tuple of arrays, each filled with the corresponding value from vals + return tuple(np.ones_like(P) * val for val in self.vals) +class ReturnMultipleValWithThreshold: + def __init__(self, vals_below, vals_above, threshold_K): + self.vals_below = vals_below + self.vals_above = vals_above + self.threshold_K = threshold_K + def __call__(self, P, T, grid=False): + if grid: + P, T_grid = np.meshgrid(P, T, indexing='ij') + result = tuple(np.where(T_grid < self.threshold_K, val_below, val_above) + for val_below, val_above in zip(self.vals_below, self.vals_above)) + else: + result = tuple(np.where(T < self.threshold_K, val_below, val_above) + for val_below, val_above in zip(self.vals_below, self.vals_above)) + return result def CheckIfEOSLoaded(EOSlabel, P_MPa, T_K, FORCE_NEW=False, minPres_MPa=None, minTres_K=None): """ Determine if we need to load a new EOS, or if we can reuse one that's already been @@ -767,8 +978,15 @@ def CheckIfEOSLoaded(EOSlabel, P_MPa, T_K, FORCE_NEW=False, minPres_MPa=None, mi """ # Create label for identifying P, T arrays - deltaP = np.maximum(np.round(np.mean(np.diff(P_MPa)), 2), 0.001) - deltaT = np.maximum(np.round(np.mean(np.diff(T_K)), 2), 0.001) + deltaP = np.round(np.maximum(np.mean(np.diff(P_MPa)), 0.001), 3) + deltaT = np.round(np.maximum(np.mean(np.diff(T_K)), 0.001), 3) + # Override min resolution if set + if minPres_MPa is not None and deltaP < minPres_MPa: + log.debug(f'deltaP of {deltaP:.2f} MPa less than minimum res setting of {minPres_MPa}. Resetting to {minPres_MPa}.') + deltaP = minPres_MPa + if minTres_K is not None and deltaT < minTres_K: + log.debug(f'deltaT of {deltaT:.2f} K less than minimum res setting of {minTres_K}. Resetting to {minTres_K}.') + deltaT = minTres_K Pmin = np.min(P_MPa) Pmax = np.max(P_MPa) Tmin = np.min(T_K) @@ -790,10 +1008,10 @@ def CheckIfEOSLoaded(EOSlabel, P_MPa, T_K, FORCE_NEW=False, minPres_MPa=None, mi EOSTmax = EOSlist.loaded[EOSlabel].Tmax nopeP = np.min(P_MPa) < EOSPmin * 0.9 or \ np.max(P_MPa) > EOSPmax * 1.1 or \ - deltaP < EOSlist.loaded[EOSlabel].deltaP * 0.9 + deltaP < EOSlist.loaded[EOSlabel].deltaP nopeT = np.min(T_K) < EOSTmin - 0.1 or \ np.max(T_K) > EOSTmax + 0.1 or \ - deltaT < EOSlist.loaded[EOSlabel].deltaT * 0.9 + deltaT < EOSlist.loaded[EOSlabel].deltaT if nopeP or nopeT: # The new inputs have at least one min/max value outside the range # of the previously loaded EOS, so we have to load a new one. @@ -806,8 +1024,8 @@ def CheckIfEOSLoaded(EOSlabel, P_MPa, T_K, FORCE_NEW=False, minPres_MPa=None, mi maxTmax = np.maximum(np.max(T_K), EOSlist.loaded[EOSlabel].Tmax) deltaP = np.round(np.minimum(np.mean(np.diff(P_MPa)), EOSlist.loaded[EOSlabel].deltaP), 2) deltaT = np.round(np.minimum(np.mean(np.diff(T_K)), EOSlist.loaded[EOSlabel].deltaT), 2) - if deltaP == 0: deltaP = 0.01 - if deltaT == 0: deltaT = 0.01 + if deltaP == 0 or np.isnan(deltaP): deltaP = 0.01 + if deltaT == 0 or np.isnan(deltaT): deltaT = 0.01 if minPres_MPa is not None and deltaP < minPres_MPa: log.warning(f'deltaP of {deltaP:.2f} MPa less than minimum res setting of {minPres_MPa}. Resetting to {minPres_MPa}.') deltaP = minPres_MPa @@ -834,13 +1052,6 @@ def CheckIfEOSLoaded(EOSlabel, P_MPa, T_K, FORCE_NEW=False, minPres_MPa=None, mi else: # This EOS has not been loaded, so we need to load it with the input parameters ALREADY_LOADED = False - # Override min resolution if set - if minPres_MPa is not None and deltaP < minPres_MPa: - log.warning(f'deltaP of {deltaP:.2f} MPa less than minimum res setting of {minPres_MPa}. Resetting to {minPres_MPa}.') - deltaP = minPres_MPa - if minTres_K is not None and deltaT < minTres_K: - log.warning(f'deltaT of {deltaT:.2f} K less than minimum res setting of {minTres_K}. Resetting to {minTres_K}.') - deltaT = minTres_K rangeLabel = f'{Pmin:.2f},{Pmax:.2f},{deltaP:.2e},' + \ f'{Tmin:.3f},{Tmax:.3f},{deltaT:.2e}' # Ensure that P_MPa is strictly ascending, namely that {Pmin and Pmax are not the same @@ -872,10 +1083,22 @@ def sfPTmTrips(P_MPa, T_K, m_molal): return np.array([(P, T, m_molal) for P, T in zip(P_MPa, T_K)], dtype='f,f,f').astype(object) # Same as above but for a grid def sfPTgrid(P_MPa, T_K): - return np.array([P_MPa, T_K], dtype=object) + if P_MPa.size == T_K.size: + P_MPa = np.append(P_MPa, P_MPa[-1]) + PT = np.array([P_MPa, T_K], dtype=object) + PT[0] = PT[0][:-1] + else: + PT = np.array([P_MPa, T_K], dtype=object) + return PT # Same for PTm grid def sfPTmGrid(P_MPa, T_K, m_molal): - return np.array([P_MPa, T_K, np.array([m_molal])], dtype=object) + if P_MPa.size == T_K.size: + P_MPa = np.append(P_MPa, P_MPa[-1]) + PT = np.array([P_MPa, T_K, np.array([m_molal])], dtype=object) + PT[0] = PT[0][:-1] + else: + PT = np.array([P_MPa, T_K, np.array([m_molal])], dtype=object) + return PT # Create callable class to act as a wrapper for SeaFreeze phase lookup class SFphase: @@ -929,18 +1152,6 @@ def __call__(self, P_MPa, T_K, grid=False): else: PTm = self.PTmTrips(P_MPa, T_K) return WhichPhase(PTm, solute=self.comp).astype(np.int_) - -class RGIwrap: - """ Creates a wrapper for passing P, T arguments as a tuple to RegularGridInterpolator """ - def __init__(self, RGIfunc, deltaP, deltaT): - self.RGIfunc = RGIfunc - self.deltaP = deltaP - self.deltaT = deltaT - - def __call__(self, P_MPa, T_K, grid=False): - if grid: - P_MPa, T_K = np.meshgrid(P_MPa, T_K, indexing='ij') - return (self.RGIfunc((P_MPa, T_K))) class SFSeismic: @@ -1002,8 +1213,6 @@ def GetPfreeze(oceanEOS, phaseTop, Tb_K, PLower_MPa=0.1, PUpper_MPa=300, PRes_MP phaseChangeUnderplateOrHPNoOcean = lambda P: 0.5 + (phaseTop - oceanEOS.fn_phase(P, Tb_K)) if (UNDERPLATE or HPNOOCEAN) is None: raise ValueError('UNDERPLATE or HPNOOCEAN is not set. Please set one of these to True or False.') - TRY_BOTH = True - UNDERPLATEOrHPNOOCEAN = True else: TRY_BOTH = False UNDERPLATEOrHPNOOCEAN = UNDERPLATE or HPNOOCEAN @@ -1024,7 +1233,7 @@ def GetPfreeze(oceanEOS, phaseTop, Tb_K, PLower_MPa=0.1, PUpper_MPa=300, PRes_MP if Pfreeze_MPa is None: try: - Pfreeze_MPa = GetZero(phaseChange, bracket=[PLower_MPa, PUpper_MPa]).root + PRes_MPa/5 + Pfreeze_MPa = GetZero(phaseChange, bracket=[PLower_MPa, PUpper_MPa]).root + PRes_MPa except ValueError: if UNDERPLATEOrHPNOOCEAN: msg = f'Tb_K of {Tb_K:.3f} is not consistent with explicit underplating or automatic high pressure no ocean ice III; ' + \ @@ -1041,7 +1250,7 @@ def GetPfreeze(oceanEOS, phaseTop, Tb_K, PLower_MPa=0.1, PUpper_MPa=300, PRes_MP raise ValueError(msg) elif TRY_BOTH: try: - Pfreeze_MPa = GetZero(phaseChangeUnderplateOrHPNoOcean, bracket=[PLower_MPa, PUpper_MPa]).root + PRes_MPa / 5 + Pfreeze_MPa = GetZero(phaseChangeUnderplateOrHPNoOcean, bracket=[PLower_MPa, PUpper_MPa]).root + PRes_MPa except ValueError: msg = f'No transition pressure was found below {PUpper_MPa:.3f} MPa ' + \ f'for ice {PhaseConv(phaseTop)}. Increase PUpper_MPa until one is found.' @@ -1082,7 +1291,7 @@ def GetTfreeze(oceanEOS, P_MPa, T_K, TfreezeRange_K=50, TRes_K=0.05): phaseChange = lambda T: 0.5 - (1 - int(oceanEOS.fn_phase(P_MPa, T) > 0)) try: - Tfreeze_K = GetZero(phaseChange, bracket=[T_K, T_K+TfreezeRange_K]).root + TRes_K/5 + Tfreeze_K = GetZero(phaseChange, bracket=[T_K, T_K+TfreezeRange_K], xtol=abs(TRes_K)).root + TRes_K except ValueError: raise ValueError(f'No melting temperature was found above {T_K:.3f} K ' + f'for ice {PhaseConv(topPhase)} at pressure {P_MPa:.3f} MPa. ' + @@ -1250,4 +1459,18 @@ def __call__(self, P_MPa, T_K, grid=False): eta_Pas[np.logical_and(T_K >= Tlow_K, T_K < Tupp_K)] = etaConst_Pas return eta_Pas + + def updateConvectionViscosity(self, etaConv_Pas, Tconv_K): + self.eta_Pas[-1] = etaConv_Pas + self.TviscTrans_K[-1] = Tconv_K + + +def GetOceanEOSLabel(compstr, wOcean_ppt, elecType, rhoType, scalingType, phaseType, EXTRAP, PORE, LOOKUP_HIRES, etaFixed_Pas, meltStr, propsStepReductionFactor): + return f'meltStr{meltStr}Comp{compstr}wppt{wOcean_ppt}elec{elecType}rho{rhoType}' + \ + f'scaling{scalingType}phase{phaseType}extrap{EXTRAP}pore{PORE}' + \ + f'hires{LOOKUP_HIRES}etaFixed{etaFixed_Pas}propsStepReductionFactor{propsStepReductionFactor}' +def GetIceEOSLabel(phaseStr, porosType, phiTop_frac, Pclosure_MPa, phiMin_frac, EXTRAP, etaFixed_Pas, TviscTrans_K): + return f'phase{phaseStr}poros{porosType}phi{phiTop_frac}Pclose{Pclosure_MPa}' + \ + f'phiMin{phiMin_frac}extrap{EXTRAP}etaFixed{etaFixed_Pas}' + \ + f'TviscTrans{TviscTrans_K}' \ No newline at end of file diff --git a/PlanetProfile/Thermodynamics/InnerEOS.py b/PlanetProfile/Thermodynamics/InnerEOS.py index ba33f9e9..80e2675e 100644 --- a/PlanetProfile/Thermodynamics/InnerEOS.py +++ b/PlanetProfile/Thermodynamics/InnerEOS.py @@ -6,20 +6,26 @@ from PlanetProfile import _ROOT from PlanetProfile.Utilities.defineStructs import Constants, EOSlist from PlanetProfile.Utilities.DataManip import ResetNearestExtrap, ReturnZeros, EOSwrapper +from PlanetProfile.Thermodynamics.ConstantEOS import ConstantEOSStruct # Assign logger log = logging.getLogger('PlanetProfile') def GetInnerEOS(EOSfname, EOSinterpMethod='nearest', nHeaders=13, Fe_EOS=False, kThermConst_WmK=None, HtidalConst_Wm3=0, porosType=None, phiTop_frac=0, Pclosure_MPa=350, phiMin_frac=None, - EXTRAP=False, wFeCore_ppt=None, wScore_ppt=None, etaFixed_Pas=None, TviscTrans_K=None): - innerEOS = PerplexEOSStruct(EOSfname, EOSinterpMethod=EOSinterpMethod, nHeaders=nHeaders, - Fe_EOS=Fe_EOS, kThermConst_WmK=kThermConst_WmK, - HtidalConst_Wm3=HtidalConst_Wm3, porosType=porosType, - phiTop_frac=phiTop_frac, Pclosure_MPa=Pclosure_MPa, - phiMin_frac=phiMin_frac, EXTRAP=EXTRAP, - wFeCore_ppt=wFeCore_ppt, wScore_ppt=wScore_ppt, - etaFixed_Pas=etaFixed_Pas, TviscTrans_K=TviscTrans_K) + EXTRAP=False, wFeCore_ppt=None, wScore_ppt=None, etaSilFixed_Pas=None, etaCoreFixed_Pas=None, TviscTrans_K=None, + doConstantProps=False, constantProperties=None): + if not doConstantProps: + innerEOS = PerplexEOSStruct(EOSfname, EOSinterpMethod=EOSinterpMethod, nHeaders=nHeaders, + Fe_EOS=Fe_EOS, kThermConst_WmK=kThermConst_WmK, + HtidalConst_Wm3=HtidalConst_Wm3, porosType=porosType, + phiTop_frac=phiTop_frac, Pclosure_MPa=Pclosure_MPa, + phiMin_frac=phiMin_frac, EXTRAP=EXTRAP, + wFeCore_ppt=wFeCore_ppt, wScore_ppt=wScore_ppt, + etaSilFixed_Pas=etaSilFixed_Pas, etaCoreFixed_Pas=etaCoreFixed_Pas, TviscTrans_K=TviscTrans_K) + else: + comp = 'core' if Fe_EOS else 'sil' + innerEOS = ConstantEOSStruct(constantProperties, TviscTrans_K=TviscTrans_K, EOStype = 'inner', innerComp = comp) if innerEOS.ALREADY_LOADED: log.debug(f'{innerEOS.comp} EOS already loaded. Reusing existing EOS.') innerEOS = EOSlist.loaded[innerEOS.EOSlabel] @@ -39,13 +45,13 @@ class PerplexEOSStruct: """ def __init__(self, EOSfname, EOSinterpMethod='nearest', nHeaders=13, Fe_EOS=False, kThermConst_WmK=None, HtidalConst_Wm3=0, porosType=None, phiTop_frac=0, Pclosure_MPa=350, phiMin_frac=None, - EXTRAP=False, wFeCore_ppt=None, wScore_ppt=None, etaFixed_Pas=None, TviscTrans_K=None): + EXTRAP=False, wFeCore_ppt=None, wScore_ppt=None, etaSilFixed_Pas=None, etaCoreFixed_Pas=None, TviscTrans_K=None): self.comp = EOSfname[:-4] self.EOSlabel = f'comp{self.comp}interp{EOSinterpMethod}kTherm{kThermConst_WmK}' + \ - f'Htidal{HtidalConst_Wm3}poros{porosType}phi{phiTop_frac}' + \ - f'Pclose{Pclosure_MPa}phiMin{phiMin_frac}extrap{EXTRAP}' + \ - f'wFeppt{wFeCore_ppt}wSppt{wScore_ppt}etaFixed{etaFixed_Pas}' + \ - f'TviscTrans{TviscTrans_K}' + f'Htidal{HtidalConst_Wm3}poros{porosType}phi{phiTop_frac}' + \ + f'Pclose{Pclosure_MPa}phiMin{phiMin_frac}extrap{EXTRAP}' + \ + f'wFeppt{wFeCore_ppt}wSppt{wScore_ppt}etaFixed{etaSilFixed_Pas}' + \ + f'TviscTrans{TviscTrans_K}' if self.EOSlabel in EOSlist.loaded.keys(): self.ALREADY_LOADED = True else: @@ -282,12 +288,12 @@ def __init__(self, EOSfname, EOSinterpMethod='nearest', nHeaders=13, Fe_EOS=Fals self.ufn_phi_frac = ReturnZeros(1) # Assign viscosity function - self.ufn_eta_Pas = ViscCoreUniform_Pas(etaSet_Pas=etaFixed_Pas, + self.ufn_eta_Pas = ViscCoreUniform_Pas(etaSet_Pas=etaCoreFixed_Pas, TviscTrans_K=TviscTrans_K) else: # Assign viscosity function - self.ufn_eta_Pas = ViscRockUniform_Pas(etaSet_Pas=etaFixed_Pas, + self.ufn_eta_Pas = ViscRockUniform_Pas(etaSet_Pas=etaSilFixed_Pas, TviscTrans_K=TviscTrans_K) if porosType is None or porosType == 'none': @@ -493,7 +499,7 @@ def nuPoisson(VP_kms, VS_kms): class ViscRockUniform_Pas: def __init__(self, etaSet_Pas=None, TviscTrans_K=None): - if etaSet_Pas is None: + if etaSet_Pas is None or any(eta is None for eta in etaSet_Pas): self.eta_Pas = Constants.etaRock_Pas else: self.eta_Pas = etaSet_Pas @@ -519,7 +525,7 @@ def __call__(self, P_MPa, T_K, grid=False): class ViscCoreUniform_Pas: def __init__(self, etaSet_Pas=None, TviscTrans_K=None): - if etaSet_Pas is None: + if etaSet_Pas is None or any(eta is None for eta in etaSet_Pas): self.eta_Pas = [Constants.etaFeSolid_Pas, Constants.etaFeLiquid_Pas] else: self.eta_Pas = etaSet_Pas @@ -540,4 +546,4 @@ def __call__(self, P_MPa, T_K, grid=False): for Tlow_K, Tupp_K, etaConst_Pas in zip(Ttrans_K[:-1], Ttrans_K[1:], self.eta_Pas): eta_Pas[np.logical_and(T_K >= Tlow_K, T_K < Tupp_K)] = etaConst_Pas - return eta_Pas + return eta_Pas \ No newline at end of file diff --git a/PlanetProfile/Thermodynamics/LayerPropagators.py b/PlanetProfile/Thermodynamics/LayerPropagators.py index 86bdf7e8..8ec1247d 100644 --- a/PlanetProfile/Thermodynamics/LayerPropagators.py +++ b/PlanetProfile/Thermodynamics/LayerPropagators.py @@ -16,12 +16,26 @@ from PlanetProfile.Thermodynamics.ThermalProfiles.IceConduction import IceIWholeConductSolid, IceIWholeConductPorous, \ IceIConductClathLidSolid, IceIConductClathLidPorous, IceIConductClathUnderplateSolid, IceIConductClathUnderplatePorous, \ IceIIIConductSolid, IceIIIConductPorous, IceVConductSolid, IceVConductPorous -from PlanetProfile.Utilities.defineStructs import Constants, EOSlist +from PlanetProfile.Thermodynamics.Geophysical import PropogateConductionFromDepth +from PlanetProfile.Utilities.defineStructs import Constants, EOSlist, Timing +import time # Assign logger log = logging.getLogger('PlanetProfile') def IceLayers(Planet, Params): + """ Wrapper function for ice layer propogation. Decides between self consistent and non-self consistent modeling. + """ + Timing.setFunctionTime(time.time()) + # Early branching for non-self-consistent modeling + if Planet.Do.NON_SELF_CONSISTENT: + Planet, Params = NonSelfConsistentIceLayer(Planet, Params) + else: + Planet, Params = SelfConsistentIceLayer(Planet, Params) + Timing.printFunctionTimeDifference('IceLayers()', time.time()) + return Planet + +def SelfConsistentIceLayer(Planet, Params): """ Layer propagation from surface downward through the ice using geophysics. Iteratively sets up the thermal profile (the density and temperature) of the layer with each pressure step for all ice layers including @@ -38,235 +52,215 @@ def IceLayers(Planet, Params): 'entirely rock+ice body.') else: if Planet.Do.ICEIh_THICKNESS: - Planet.Bulk.Tb_K = GetIceShellTFreeze(Planet, Params) - Planet.Steps.nIbottom = Planet.Steps.nClath + Planet.Steps.nIceI - Planet.Steps.nIIIbottom = Planet.Steps.nIbottom + Planet.Steps.nIceIIILitho - Planet.Steps.nSurfIce = Planet.Steps.nIIIbottom + Planet.Steps.nIceVLitho - # Assign phase values for near-surface ices - Planet.phase[:Planet.Steps.nIbottom] = 1 # Ice Ih layers (some will be reassigned if Do.CLATHRATE = True) - Planet.phase[Planet.Steps.nIbottom:Planet.Steps.nIIIbottom] = 3 # Ice III layers - Planet.phase[Planet.Steps.nIIIbottom:Planet.Steps.nSurfIce] = 5 # Ice V layers - # Finally, we need to set up the logical array for ice convection layers (this will be default False and overriden if we do ice convection)) - Planet.Steps.iConv = np.zeros(Planet.Steps.nSurfIce, dtype=bool) - - - # Get the pressure consistent with the bottom of the surface ice layer that is - # consistent with the choice of Tb_K we suppose for this model - Planet.PbI_MPa = GetPfreeze(Planet.Ocean.meltEOS, 1, Planet.Bulk.Tb_K, - PLower_MPa=Planet.PfreezeLower_MPa, PUpper_MPa=Planet.PfreezeUpper_MPa, - PRes_MPa=Planet.PfreezeRes_MPa, UNDERPLATE=(Planet.Do.BOTTOM_ICEIII or Planet.Do.BOTTOM_ICEV), HPNOOCEAN=Planet.Do.NO_OCEAN_EXCEPT_INNER_ICES, - ALLOW_BROKEN_MODELS=Params.ALLOW_BROKEN_MODELS, DO_EXPLOREOGRAM=Params.DO_EXPLOREOGRAM) - if(Planet.Do.CLATHRATE and - (Planet.Bulk.clathType == 'bottom' or - Planet.Bulk.clathType == 'whole')): - PbClath_MPa = Planet.Ocean.ClathDissoc.PbClath_MPa() - if not np.isnan(PbClath_MPa): - log.debug(f'Clathrate dissociation pressure: {PbClath_MPa:.3f} MPa.') - if PbClath_MPa < Planet.PbI_MPa: - raise ValueError('Dissociation pressure for clathrates is lower than the ice Ih ' + - 'melting pressure consistent with Bulk.Tb_K. This means ice Ih layers ' + - 'will be found underneath the clathrate layers, inconsistent with the ' + - 'assumption that clathrates are in contact with the ocean. Increase ' + - f'Bulk.Tb_K until PbClath_MPa ({PbClath_MPa:.3f} MPa) ' + - f'exceeds PbI_MPa ({Planet.PbI_MPa:.3f} MPa).') - Planet.PbI_MPa = PbClath_MPa + Planet = GetIceShellTFreeze(Planet, Params) else: - if np.isnan(Planet.PbI_MPa): - msg = f'No valid phase transition was found for Tb_K = {Planet.Bulk.Tb_K:.3f} K for P in the range ' + \ - f'[{Planet.PfreezeLower_MPa:.1f} MPa, {Planet.PfreezeUpper_MPa:.1f} MPa]. ' + \ - 'This likely means Tb_K is too high and the phase at the lower end of this range matches ' + \ - 'the phase at the upper end. Try decreasing Tb_K or increasing Planet.PfreezeUpper_MPa. ' + \ - 'For this model, the ice shell will be set to zero thickness.' - if (not Params.DO_EXPLOREOGRAM) and (not Params.DO_INDUCTOGRAM): - if Planet.Bulk.Tb_K > 271: - log.warning(msg) - else: + Planet.Steps.nIbottom = Planet.Steps.nClath + Planet.Steps.nIceI + Planet.Steps.nIIIbottom = Planet.Steps.nIbottom + Planet.Steps.nIceIIILitho + Planet.Steps.nSurfIce = Planet.Steps.nIIIbottom + Planet.Steps.nIceVLitho + # Assign phase values for near-surface ices + Planet.phase[:Planet.Steps.nIbottom] = 1 # Ice Ih layers (some will be reassigned if Do.CLATHRATE = True) + Planet.phase[Planet.Steps.nIbottom:Planet.Steps.nIIIbottom] = 3 # Ice III layers + Planet.phase[Planet.Steps.nIIIbottom:Planet.Steps.nSurfIce] = 5 # Ice V layers + # Finally, we need to set up the logical array for ice convection layers (this will be default False and overriden if we do ice convection)) + Planet.Steps.iConv = np.zeros(Planet.Steps.nSurfIce, dtype=bool) + + + # Get the pressure consistent with the bottom of the surface ice layer that is + # consistent with the choice of Tb_K we suppose for this model + Planet.PbI_MPa = GetPfreeze(Planet.Ocean.meltEOS, 1, Planet.Bulk.Tb_K, + PLower_MPa=Planet.PfreezeLower_MPa, PUpper_MPa=Planet.PfreezeUpper_MPa, + PRes_MPa=Planet.PfreezeRes_MPa, UNDERPLATE=(Planet.Do.BOTTOM_ICEIII or Planet.Do.BOTTOM_ICEV), HPNOOCEAN=Planet.Do.NO_OCEAN_EXCEPT_INNER_ICES, + ALLOW_BROKEN_MODELS=Params.ALLOW_BROKEN_MODELS, DO_EXPLOREOGRAM=Params.DO_EXPLOREOGRAM) + if(Planet.Do.CLATHRATE and + (Planet.Bulk.clathType == 'bottom' or + Planet.Bulk.clathType == 'whole')): + PbClath_MPa = Planet.Ocean.ClathDissoc.PbClath_MPa() + if not np.isnan(PbClath_MPa): + log.debug(f'Clathrate dissociation pressure: {PbClath_MPa:.3f} MPa.') + if PbClath_MPa < Planet.PbI_MPa: + raise ValueError('Dissociation pressure for clathrates is lower than the ice Ih ' + + 'melting pressure consistent with Bulk.Tb_K. This means ice Ih layers ' + + 'will be found underneath the clathrate layers, inconsistent with the ' + + 'assumption that clathrates are in contact with the ocean. Increase ' + + f'Bulk.Tb_K until PbClath_MPa ({PbClath_MPa:.3f} MPa) ' + + f'exceeds PbI_MPa ({Planet.PbI_MPa:.3f} MPa).') + Planet.PbI_MPa = PbClath_MPa + else: + if np.isnan(Planet.PbI_MPa): + msg = f'No valid phase transition was found for Tb_K = {Planet.Bulk.Tb_K:.3f} K for P in the range ' + \ + f'[{Planet.PfreezeLower_MPa:.1f} MPa, {Planet.PfreezeUpper_MPa:.1f} MPa]. ' + \ + 'This likely means Tb_K is too high and the phase at the lower end of this range matches ' + \ + 'the phase at the upper end. Try decreasing Tb_K or increasing Planet.PfreezeUpper_MPa. ' + \ + 'For this model, the ice shell will be set to zero thickness.' + if (not Params.DO_EXPLOREOGRAM) and (not Params.DO_INDUCTOGRAM): + if Planet.Bulk.Tb_K > 271: + log.warning(msg) + else: + if Params.ALLOW_BROKEN_MODELS: + Planet.Do.VALID = False + Planet.invalidReason = f'No valid phase transition was found for Tb_K = {Planet.Bulk.Tb_K:.3f} K for P in the range ' + \ + f'[{Planet.PfreezeLower_MPa:.1f} MPa, {Planet.PfreezeUpper_MPa:.1f} MPa]. ' + else: + raise ValueError(msg) + Planet.PbI_MPa = 0.0 + log.debug(f'Ice Ih transition pressure: {Planet.PbI_MPa:.3f} MPa.') + if Planet.PbI_MPa > 0: + # Now do the same for HP ices, if present, to make sure we have a possible configuration before continuing + if Planet.Do.BOTTOM_ICEV: + Planet.PbIII_MPa = GetPfreeze(Planet.Ocean.meltEOS, 3, Planet.Bulk.TbIII_K, + PLower_MPa=Planet.PbI_MPa, PUpper_MPa=Planet.Ocean.PHydroMax_MPa, + PRes_MPa=Planet.PfreezeRes_MPa, UNDERPLATE=True, HPNOOCEAN=False, + ALLOW_BROKEN_MODELS=Params.ALLOW_BROKEN_MODELS, DO_EXPLOREOGRAM=Params.DO_EXPLOREOGRAM) + if(Planet.PbIII_MPa <= Planet.PbI_MPa) or np.isnan(Planet.PbIII_MPa): + msg = 'Ice III bottom pressure is not greater than ice I bottom pressure. ' + \ + 'This likely indicates TbIII_K is too high for the corresponding Tb_K.' + \ + f'\nPbI_MPa = {Planet.PbI_MPa:.3f}' + \ + f', Tb_K = {Planet.Bulk.Tb_K:.3f}' + \ + f'\nPbIII_MPa = {Planet.PbIII_MPa:.3f}' + \ + f', TbIII_K = {Planet.Bulk.TbIII_K:.3f}' if Params.ALLOW_BROKEN_MODELS: + Planet.PbIII_MPa = np.nan + if Params.DO_EXPLOREOGRAM: + log.info(msg) + else: + log.error(msg) Planet.Do.VALID = False - Planet.invalidReason = f'No valid phase transition was found for Tb_K = {Planet.Bulk.Tb_K:.3f} K for P in the range ' + \ - f'[{Planet.PfreezeLower_MPa:.1f} MPa, {Planet.PfreezeUpper_MPa:.1f} MPa]. ' + Planet.invalidReason = 'TbIII_K is too high compared to Tb_K' else: raise ValueError(msg) - Planet.PbI_MPa = 0.0 - log.debug(f'Ice Ih transition pressure: {Planet.PbI_MPa:.3f} MPa.') - - if Planet.PbI_MPa > 0: - # Now do the same for HP ices, if present, to make sure we have a possible configuration before continuing - if Planet.Do.BOTTOM_ICEV: - Planet.PbIII_MPa = GetPfreeze(Planet.Ocean.meltEOS, 3, Planet.Bulk.TbIII_K, - PLower_MPa=Planet.PbI_MPa, PUpper_MPa=Planet.Ocean.PHydroMax_MPa, - PRes_MPa=Planet.PfreezeRes_MPa, UNDERPLATE=True, HPNOOCEAN=False, - ALLOW_BROKEN_MODELS=Params.ALLOW_BROKEN_MODELS, DO_EXPLOREOGRAM=Params.DO_EXPLOREOGRAM) - if(Planet.PbIII_MPa <= Planet.PbI_MPa) or np.isnan(Planet.PbIII_MPa): - msg = 'Ice III bottom pressure is not greater than ice I bottom pressure. ' + \ - 'This likely indicates TbIII_K is too high for the corresponding Tb_K.' + \ - f'\nPbI_MPa = {Planet.PbI_MPa:.3f}' + \ - f', Tb_K = {Planet.Bulk.Tb_K:.3f}' + \ - f'\nPbIII_MPa = {Planet.PbIII_MPa:.3f}' + \ - f', TbIII_K = {Planet.Bulk.TbIII_K:.3f}' - if Params.ALLOW_BROKEN_MODELS: - Planet.PbIII_MPa = np.nan - if Params.DO_EXPLOREOGRAM: - log.info(msg) - else: - log.error(msg) - Planet.Do.VALID = False - Planet.invalidReason = 'TbIII_K is too high compared to Tb_K' + if not np.isnan(Planet.PbIII_MPa): + Planet.PbV_MPa = GetPfreeze(Planet.Ocean.meltEOS, 5, Planet.Bulk.TbV_K, + PLower_MPa=Planet.PbIII_MPa, PUpper_MPa=Planet.Ocean.PHydroMax_MPa, + PRes_MPa=Planet.PfreezeRes_MPa, UNDERPLATE=False, HPNOOCEAN=False, + ALLOW_BROKEN_MODELS=Params.ALLOW_BROKEN_MODELS, + DO_EXPLOREOGRAM=Params.DO_EXPLOREOGRAM) else: - raise ValueError(msg) - if not np.isnan(Planet.PbIII_MPa): - Planet.PbV_MPa = GetPfreeze(Planet.Ocean.meltEOS, 5, Planet.Bulk.TbV_K, - PLower_MPa=Planet.PbIII_MPa, PUpper_MPa=Planet.Ocean.PHydroMax_MPa, + Planet.PbV_MPa = np.nan + Planet.Pb_MPa = Planet.PbV_MPa + if(Planet.PbV_MPa <= Planet.PbIII_MPa) or np.isnan(Planet.PbV_MPa): + msg = 'Ice V bottom pressure is not greater than ice III bottom pressure. ' + \ + 'This likely indicates TbV_K is too high for the corresponding TbIII_K.' + \ + f'\nPbIII_MPa = {Planet.PbIII_MPa:.3f}' + \ + f', TbIII_K = {Planet.Bulk.TbIII_K:.3f}' + \ + f'\nPbV_MPa = {Planet.PbV_MPa:.3f}' + \ + f', TbV_K = {Planet.Bulk.TbV_K:.3f}' + if Params.ALLOW_BROKEN_MODELS: + Planet.PbIII_MPa = np.nan + if Params.DO_EXPLOREOGRAM: + log.info(msg) + else: + log.error(msg) + Planet.Do.VALID = False + Planet.invalidReason = 'TbV_K is too high compared to Tb_K' + else: + raise ValueError(msg) + elif Planet.Do.BOTTOM_ICEIII: + Planet.PbIII_MPa = GetPfreeze(Planet.Ocean.meltEOS, 3, Planet.Bulk.TbIII_K, + PLower_MPa=Planet.PbI_MPa, PUpper_MPa=Planet.Ocean.PHydroMax_MPa, PRes_MPa=Planet.PfreezeRes_MPa, UNDERPLATE=False, HPNOOCEAN=False, ALLOW_BROKEN_MODELS=Params.ALLOW_BROKEN_MODELS, DO_EXPLOREOGRAM=Params.DO_EXPLOREOGRAM) - else: - Planet.PbV_MPa = np.nan - Planet.Pb_MPa = Planet.PbV_MPa - if(Planet.PbV_MPa <= Planet.PbIII_MPa) or np.isnan(Planet.PbV_MPa): - msg = 'Ice V bottom pressure is not greater than ice III bottom pressure. ' + \ - 'This likely indicates TbV_K is too high for the corresponding TbIII_K.' + \ - f'\nPbIII_MPa = {Planet.PbIII_MPa:.3f}' + \ - f', TbIII_K = {Planet.Bulk.TbIII_K:.3f}' + \ - f'\nPbV_MPa = {Planet.PbV_MPa:.3f}' + \ - f', TbV_K = {Planet.Bulk.TbV_K:.3f}' - if Params.ALLOW_BROKEN_MODELS: - Planet.PbIII_MPa = np.nan - if Params.DO_EXPLOREOGRAM: - log.info(msg) - else: - log.error(msg) - Planet.Do.VALID = False - Planet.invalidReason = 'TbV_K is too high compared to Tb_K' - else: - raise ValueError(msg) - elif Planet.Do.BOTTOM_ICEIII: - Planet.PbIII_MPa = GetPfreeze(Planet.Ocean.meltEOS, 3, Planet.Bulk.TbIII_K, - PLower_MPa=Planet.PbI_MPa, PUpper_MPa=Planet.Ocean.PHydroMax_MPa, - PRes_MPa=Planet.PfreezeRes_MPa, UNDERPLATE=False, HPNOOCEAN=False, - ALLOW_BROKEN_MODELS=Params.ALLOW_BROKEN_MODELS, - DO_EXPLOREOGRAM=Params.DO_EXPLOREOGRAM) - if(Planet.PbIII_MPa <= Planet.PbI_MPa) or np.isnan(Planet.PbIII_MPa): - msg = 'Ice III bottom pressure is not greater than ice I bottom pressure. ' + \ - 'This likely indicates TbIII_K is too high for the corresponding Tb_K.' + \ - f'\nPbI_MPa = {Planet.PbI_MPa:.3f}' + \ - f', Tb_K = {Planet.Bulk.Tb_K:.3f}' + \ - f'\nPbIII_MPa = {Planet.PbIII_MPa:.3f}' + \ - f', TbIII_K = {Planet.Bulk.TbIII_K:.3f}' - if Params.ALLOW_BROKEN_MODELS: - Planet.PbIII_MPa = np.nan - if Params.DO_EXPLOREOGRAM: - log.info(msg) + if(Planet.PbIII_MPa <= Planet.PbI_MPa) or np.isnan(Planet.PbIII_MPa): + msg = 'Ice III bottom pressure is not greater than ice I bottom pressure. ' + \ + 'This likely indicates TbIII_K is too high for the corresponding Tb_K.' + \ + f'\nPbI_MPa = {Planet.PbI_MPa:.3f}' + \ + f', Tb_K = {Planet.Bulk.Tb_K:.3f}' + \ + f'\nPbIII_MPa = {Planet.PbIII_MPa:.3f}' + \ + f', TbIII_K = {Planet.Bulk.TbIII_K:.3f}' + if Params.ALLOW_BROKEN_MODELS: + Planet.PbIII_MPa = np.nan + if Params.DO_EXPLOREOGRAM: + log.info(msg) + else: + log.error(msg) + Planet.Do.VALID = False + Planet.invalidReason = 'TbIII_K is too high compared to Tb_K' else: - log.error(msg) - Planet.Do.VALID = False - Planet.invalidReason = 'TbIII_K is too high compared to Tb_K' - else: - raise ValueError(msg) - Planet.Pb_MPa = Planet.PbIII_MPa - else: - Planet.Pb_MPa = Planet.PbI_MPa - - elif Planet.Pb_MPa == 0 and Planet.Bulk.Tsurf_K == Planet.Bulk.Tb_K: - # This config needs to be caught in SetupInit. - pass - - else: - Planet.Pb_MPa = np.nan - Planet.Do.VALID = False - Planet.invalidReason = 'Tb_K too high compared to underplate TbIII_K and/or TbV_K' - if not Params.ALLOW_BROKEN_MODELS: - raise RuntimeError('Unable to find a valid pressure corresponding to Bulk.TbX_K values. ' + - f'This is usually because Bulk.Tb_K (currently {Planet.Bulk.Tb_K:.3f}) ' + - 'is set too high. Try decreasing Bulk.Tb_K before running again.') - - # Now, we want to check for a convective profile. First, we need to get zb_km, so we need to suppose - # a whole-layer conductive profile. The densities will change slightly, so we depart from self-consistency - # here. Repeated applications of IceConvect will get closer to self-consistency. - - if Planet.Pb_MPa > 0 and Planet.Pb_MPa < Planet.P_MPa[0]: - negDeltaPmsg = f'Calculated Pb value of {Planet.Pb_MPa:.2f} MPa is less than surface pressure of {Planet.P_MPa[0]:.2f} MPa. ' + \ - 'This likely means Tb_K is set too high. Try to decrease and run again to get a valid model.' - if Params.ALLOW_BROKEN_MODELS: - log.warning(negDeltaPmsg + ' ALLOW_BROKEN_MODELS is True, so execution will continue.') - Planet.Do.VALID = False - Planet.invalidReason = 'Pb_MPa is greater than Psurf_MPa' - else: - raise ValueError(negDeltaPmsg) - - elif Planet.Pb_MPa > 0: - if Planet.Do.CLATHRATE: - """ For ice shells insulated by a layer of clathrate at the surface or against the bottom - Calculates state variables of the layer with each pressure step - Applies different behavior based on Bulk.clathType: - 'top': Models a conductive lid of clathrates limited to Bulk.clathMaxThick_m or eLid_m - (calculated for convection), whichever is less - 'bottom': Models a clathrate layer at the ice-ocean interface with a fixed thickness - equal to Bulk.clathMaxThick_m. Assumes a purely conductive lid, as justified in - Kamata et al. (2019) for Pluto: https://doi.org/10.1038/s41561-019-0369-8 - 'whole': Models clathrates as present throughout the outer ice shell, checking for - convection, and assumes no ice I is present in the shell. This option is handled in IceLayers. - """ - if Planet.Do.MIXED_CLATHRATE_ICE: - phaseIndex = Constants.phaseClath + 1 + raise ValueError(msg) + Planet.Pb_MPa = Planet.PbIII_MPa else: - phaseIndex = Constants.phaseClath - if Planet.Bulk.clathType == 'top': - log.debug('Applying clathrate lid conduction.') - - Planet.phase[:Planet.Steps.nClath] = phaseIndex - if Planet.Do.POROUS_ICE: - Planet = IceIConductClathLidPorous(Planet, Params) - else: - Planet = IceIConductClathLidSolid(Planet, Params) - elif Planet.Bulk.clathType == 'bottom': - log.debug('Applying clathrate underplating to ice I shell.') - Planet.phase[Planet.Steps.nIceI:Planet.Steps.nIbottom] = phaseIndex - if Planet.Do.POROUS_ICE: - Planet = IceIConductClathUnderplatePorous(Planet, Params) - else: - Planet = IceIConductClathUnderplateSolid(Planet, Params) + Planet.Pb_MPa = Planet.PbI_MPa + elif Planet.Pb_MPa == 0 and Planet.Bulk.Tsurf_K == Planet.Bulk.Tb_K: + # This config needs to be caught in SetupInit. + pass - elif Planet.Bulk.clathType == 'whole': - log.debug('Applying whole-shell clathrate modeling with possible convection.') - Planet.phase[:Planet.Steps.nIbottom] = phaseIndex - if Planet.Do.POROUS_ICE: - Planet = IceIWholeConductPorous(Planet, Params) - else: - Planet = IceIWholeConductSolid(Planet, Params) - else: - raise ValueError(f'Bulk.clathType option "{Planet.Bulk.clathType}" is not supported. ' + - 'Options are "top", "bottom", and "whole".') else: - if Planet.Do.POROUS_ICE: - Planet = IceIWholeConductPorous(Planet, Params) + Planet.Pb_MPa = np.nan + Planet.Do.VALID = False + Planet.invalidReason = 'Tb_K too high compared to underplate TbIII_K and/or TbV_K' + if not Params.ALLOW_BROKEN_MODELS: + raise RuntimeError('Unable to find a valid pressure corresponding to Bulk.TbX_K values. ' + + f'This is usually because Bulk.Tb_K (currently {Planet.Bulk.Tb_K:.3f}) ' + + 'is set too high. Try decreasing Bulk.Tb_K before running again.') + + # Now, we want to check for a convective profile. First, we need to get zb_km, so we need to suppose + # a whole-layer conductive profile. The densities will change slightly, so we depart from self-consistency + # here. Repeated applications of IceConvect will get closer to self-consistency. + + if Planet.Pb_MPa > 0 and Planet.Pb_MPa < Planet.P_MPa[0]: + negDeltaPmsg = f'Calculated Pb value of {Planet.Pb_MPa:.2f} MPa is less than surface pressure of {Planet.P_MPa[0]:.2f} MPa. ' + \ + 'This likely means Tb_K is set too high. Try to decrease and run again to get a valid model.' + if Params.ALLOW_BROKEN_MODELS: + log.warning(negDeltaPmsg + ' ALLOW_BROKEN_MODELS is True, so execution will continue.') + Planet.Do.VALID = False + Planet.invalidReason = 'Pb_MPa is greater than Psurf_MPa' else: - Planet = IceIWholeConductSolid(Planet, Params) + raise ValueError(negDeltaPmsg) + + elif Planet.Pb_MPa > 0: + if Planet.Do.CLATHRATE: + """ For ice shells insulated by a layer of clathrate at the surface or against the bottom + Calculates state variables of the layer with each pressure step + Applies different behavior based on Bulk.clathType: + 'top': Models a conductive lid of clathrates limited to Bulk.clathMaxThick_m or eLid_m + (calculated for convection), whichever is less + 'bottom': Models a clathrate layer at the ice-ocean interface with a fixed thickness + equal to Bulk.clathMaxThick_m. Assumes a purely conductive lid, as justified in + Kamata et al. (2019) for Pluto: https://doi.org/10.1038/s41561-019-0369-8 + 'whole': Models clathrates as present throughout the outer ice shell, checking for + convection, and assumes no ice I is present in the shell. This option is handled in IceLayers. + """ + if Planet.Do.MIXED_CLATHRATE_ICE: + phaseIndex = Constants.phaseClath + 1 + else: + phaseIndex = Constants.phaseClath + if Planet.Bulk.clathType == 'top': + log.debug('Applying clathrate lid conduction.') - log.debug('Upper ice initial conductive profile complete.') + Planet.phase[:Planet.Steps.nClath] = phaseIndex + if Planet.Do.POROUS_ICE: + Planet = IceIConductClathLidPorous(Planet, Params) + else: + Planet = IceIConductClathLidSolid(Planet, Params) + elif Planet.Bulk.clathType == 'bottom': + log.debug('Applying clathrate underplating to ice I shell.') + Planet.phase[Planet.Steps.nIceI:Planet.Steps.nIbottom] = phaseIndex + if Planet.Do.POROUS_ICE: + Planet = IceIConductClathUnderplatePorous(Planet, Params) + else: + Planet = IceIConductClathUnderplateSolid(Planet, Params) - if not Planet.Do.NO_ICE_CONVECTION and not Planet.Bulk.clathType == 'bottom': - # Record zb_m to see if it gets adjusted significantly - zbOld_m = Planet.z_m[Planet.Steps.nIbottom-1] + 0.0 - # Now check for convective region and get dimensions if present - if Planet.Do.CLATHRATE and Planet.Bulk.clathType == 'whole': - if Planet.Do.POROUS_ICE: - Planet = ClathShellConvectPorous(Planet, Params) + elif Planet.Bulk.clathType == 'whole': + log.debug('Applying whole-shell clathrate modeling with possible convection.') + Planet.phase[:Planet.Steps.nIbottom] = phaseIndex + if Planet.Do.POROUS_ICE: + Planet = IceIWholeConductPorous(Planet, Params) + else: + Planet = IceIWholeConductSolid(Planet, Params) else: - Planet = ClathShellConvectSolid(Planet, Params) + raise ValueError(f'Bulk.clathType option "{Planet.Bulk.clathType}" is not supported. ' + + 'Options are "top", "bottom", and "whole".') else: if Planet.Do.POROUS_ICE: - Planet = IceIConvectPorous(Planet, Params) + Planet = IceIWholeConductPorous(Planet, Params) else: - Planet = IceIConvectSolid(Planet, Params) - if Planet.Bulk.clathType == 'top': - # Reassign clathrate/ice I transition following convection calcs - Planet.zClath_m = Planet.z_m[Planet.Steps.nClath] - # Run IceIConvect a second time if zbI_m changed by more than a set tolerance - if(np.abs(Planet.z_m[Planet.Steps.nIbottom-1] - zbOld_m)/Planet.z_m[Planet.Steps.nIbottom-1] > Planet.Bulk.zbChangeTol_frac): - log.debug('The bottom depth of surface ice I changed by ' + - f'{(Planet.z_m[Planet.Steps.nIbottom-1] - zbOld_m)/1e3:.2f} km from IceIConvect, which is greater than ' + - f'{Planet.Bulk.zbChangeTol_frac * 100:.0f}%. running IceIConvect a second time...') + Planet = IceIWholeConductSolid(Planet, Params) + + log.debug('Upper ice initial conductive profile complete.') + if not Planet.Do.NO_ICE_CONVECTION and not Planet.Bulk.clathType == 'bottom': + # Record zb_m to see if it gets adjusted significantly + zbOld_m = Planet.z_m[Planet.Steps.nIbottom-1] + 0.0 + # Now check for convective region and get dimensions if present if Planet.Do.CLATHRATE and Planet.Bulk.clathType == 'whole': if Planet.Do.POROUS_ICE: Planet = ClathShellConvectPorous(Planet, Params) @@ -277,62 +271,151 @@ def IceLayers(Planet, Params): Planet = IceIConvectPorous(Planet, Params) else: Planet = IceIConvectSolid(Planet, Params) + if Planet.Bulk.clathType == 'top': + # Reassign clathrate/ice I transition following convection calcs + Planet.zClath_m = Planet.z_m[Planet.Steps.nClath] + # Run IceIConvect a second time if zbI_m changed by more than a set tolerance + if(np.abs(Planet.z_m[Planet.Steps.nIbottom-1] - zbOld_m)/Planet.z_m[Planet.Steps.nIbottom-1] > Planet.Bulk.zbChangeTol_frac): + log.debug('The bottom depth of surface ice I changed by ' + + f'{(Planet.z_m[Planet.Steps.nIbottom-1] - zbOld_m)/1e3:.2f} km from IceIConvect, which is greater than ' + + f'{Planet.Bulk.zbChangeTol_frac * 100:.0f}%. running IceIConvect a second time...') + if Planet.Do.CLATHRATE and Planet.Bulk.clathType == 'whole': + if Planet.Do.POROUS_ICE: + Planet = ClathShellConvectPorous(Planet, Params) + else: + Planet = ClathShellConvectSolid(Planet, Params) + else: + if Planet.Do.POROUS_ICE: + Planet = IceIConvectPorous(Planet, Params) + else: + Planet = IceIConvectSolid(Planet, Params) + else: + if Planet.Do.NO_ICE_CONVECTION: + log.debug('NO_ICE_CONVECTION is True -- skipping ice I convection calculations.') + Planet.RaConvect = np.nan + Planet.RaCrit = np.nan + Planet.Tconv_K = np.nan + Planet.etaConv_Pas = np.nan + + Planet.eLid_m = Planet.z_m[Planet.Steps.nSurfIce] + Planet.Dconv_m = 0.0 + Planet.deltaTBL_m = 0.0 + # Find the surface heat flux from the conductive profile. This assumes there is no tidal heating! + Planet.Ocean.QfromMantle_W = Planet.kTherm_WmK[Planet.Steps.nIbottom-2] * Planet.T_K[Planet.Steps.nIbottom-2] / \ + (Planet.z_m[Planet.Steps.nIbottom-1] - Planet.z_m[Planet.Steps.nIbottom-2]) \ + * np.log(Planet.T_K[Planet.Steps.nIbottom-1]/Planet.T_K[Planet.Steps.nIbottom-2]) \ + * 4*np.pi*(Planet.Bulk.R_m - Planet.z_m[Planet.Steps.nIbottom-1])**2 + + # Additional adiabats + possible convection in ice III and/or V underplate layers -- + # for thick, cold ice shells and saline oceans + if Planet.Do.BOTTOM_ICEV: + log.debug('Modeling ice III and V underplating...') + Planet = IceIIIUnderplate(Planet, Params) + Planet = IceVUnderplate(Planet, Params) + elif Planet.Do.BOTTOM_ICEIII: + log.debug('Modeling ice III underplating...') + Planet = IceIIIUnderplate(Planet, Params) + + # Print and save transition pressure and upper ice thickness + Planet.zb_km = Planet.z_m[Planet.Steps.nSurfIce] / 1e3 + log.info(f'Upper ice transition pressure: {Planet.Pb_MPa:.3f} MPa, ' + + f'thickness: {Planet.zb_km:.3f} km.') + + # Set surface HP ice layers to have negative phase ID to differentiate from in-ocean HP ices + indsHP = np.where(np.logical_and(abs(Planet.phase[:Planet.Steps.nSurfIce]) > 1, + abs(Planet.phase[:Planet.Steps.nSurfIce]) <= 6))[0] + Planet.phase[:Planet.Steps.nSurfIce][indsHP] = -Planet.phase[:Planet.Steps.nSurfIce][indsHP] + + # Get heat flux out of the possibly convecting region + Planet.qCon_Wm2 = Planet.Ocean.QfromMantle_W / (4*np.pi * (Planet.Bulk.R_m - Planet.z_m[Planet.Steps.nSurfIce])**2) + # Get heat flux at the surface, assuming Htidal = Qrad = 0 throughout the entire hydrosphere. + Planet.qSurf_Wm2 = Planet.Ocean.QfromMantle_W / (4*np.pi * Planet.Bulk.R_m**2) + + elif Planet.Pb_MPa == 0 and Planet.Bulk.Tsurf_K == Planet.Bulk.Tb_K: + Planet.zb_km = 0 + # This configuration should be accounted for in SetupInit. else: - if Planet.Do.NO_ICE_CONVECTION: - log.debug('NO_ICE_CONVECTION is True -- skipping ice I convection calculations.') - Planet.RaConvect = np.nan - Planet.RaCrit = np.nan - Planet.Tconv_K = np.nan - Planet.etaConv_Pas = np.nan - - Planet.eLid_m = Planet.z_m[Planet.Steps.nSurfIce] - Planet.Dconv_m = 0.0 - Planet.deltaTBL_m = 0.0 - # Find the surface heat flux from the conductive profile. This assumes there is no tidal heating! - Planet.Ocean.QfromMantle_W = Planet.kTherm_WmK[Planet.Steps.nIbottom-2] * Planet.T_K[Planet.Steps.nIbottom-2] / \ - (Planet.z_m[Planet.Steps.nIbottom-1] - Planet.z_m[Planet.Steps.nIbottom-2]) \ - * np.log(Planet.T_K[Planet.Steps.nIbottom-1]/Planet.T_K[Planet.Steps.nIbottom-2]) \ - * 4*np.pi*(Planet.Bulk.R_m - Planet.z_m[Planet.Steps.nIbottom-1])**2 - - # Additional adiabats + possible convection in ice III and/or V underplate layers -- - # for thick, cold ice shells and saline oceans - if Planet.Do.BOTTOM_ICEV: - log.debug('Modeling ice III and V underplating...') - Planet = IceIIIUnderplate(Planet, Params) - Planet = IceVUnderplate(Planet, Params) - elif Planet.Do.BOTTOM_ICEIII: - log.debug('Modeling ice III underplating...') - Planet = IceIIIUnderplate(Planet, Params) - - # Print and save transition pressure and upper ice thickness - Planet.zb_km = Planet.z_m[Planet.Steps.nSurfIce] / 1e3 - log.info(f'Upper ice transition pressure: {Planet.Pb_MPa:.3f} MPa, ' + - f'thickness: {Planet.zb_km:.3f} km.') - - # Set surface HP ice layers to have negative phase ID to differentiate from in-ocean HP ices - indsHP = np.where(np.logical_and(abs(Planet.phase[:Planet.Steps.nSurfIce]) > 1, - abs(Planet.phase[:Planet.Steps.nSurfIce]) <= 6))[0] - Planet.phase[:Planet.Steps.nSurfIce][indsHP] = -Planet.phase[:Planet.Steps.nSurfIce][indsHP] - - # Get heat flux out of the possibly convecting region - Planet.qCon_Wm2 = Planet.Ocean.QfromMantle_W / (4*np.pi * (Planet.Bulk.R_m - Planet.z_m[Planet.Steps.nSurfIce])**2) - # Get heat flux at the surface, assuming Htidal = Qrad = 0 throughout the entire hydrosphere. - Planet.qSurf_Wm2 = Planet.Ocean.QfromMantle_W / (4*np.pi * Planet.Bulk.R_m**2) - - elif Planet.Pb_MPa == 0 and Planet.Bulk.Tsurf_K == Planet.Bulk.Tb_K: - Planet.zb_km = 0 - # This configuration should be accounted for in SetupInit. - else: - # Set necessary empty variables for when we have an invalid profile - Planet.Do.VALID = False - Planet.invalidReason = 'Pb_MPa is negative' - Planet.zb_km, Planet.PbClathMax_MPa, Planet.PbIII_MPa, Planet.PbV_MPa, Planet.RaConvect, \ + # Set necessary empty variables for when we have an invalid profile + Planet.Do.VALID = False + Planet.invalidReason = 'Pb_MPa is negative' + Planet.zb_km, Planet.PbClathMax_MPa, Planet.PbIII_MPa, Planet.PbV_MPa, Planet.RaConvect, \ + Planet.RaCrit, Planet.Tconv_K, Planet.TconvIII_K, Planet.TconvV_K, Planet.etaConv_Pas, \ + Planet.etaConvIII_Pas, Planet.etaConvV_Pas, Planet.eLid_m, Planet.Dconv_m, Planet.deltaTBL_m, \ + Planet.qCon_Wm2, Planet.qSurf_Wm2, Planet.TclathTrans_K, Planet.Ocean.QfromMantle_W \ + = (np.nan for _ in range(19)) + + return Planet, Params + +def NonSelfConsistentIceLayer(Planet, Params): + """ Non-self-consistent ice layer modeling using specified mean properties + instead of detailed EOS calculations. Uses user-specified layer densities, + thicknesses, and thermal properties. + + Only setup for ice Ih layers at the moment #TODO + + Assigns Planet attributes: + """ + log.debug('Using non-self-consistent ice layer modeling.') + if Planet.Do.PARTIAL_DIFFERENTIATION or Planet.Do.NO_DIFFERENTIATION or Planet.Do.CLATHRATE: #TODO: Revisit in future + raise ValueError('Non-self-consistent ice layer modeling is not supported for partial or no differentiation or clathrate modeling at this moment.') + # Check that ice shell thickness is set + if Planet.zb_km is None: + raise ValueError('Planet.zb_km must be set for non-self-consistent ice modeling.') + + # Set unnecessary variables used in self consistent modeling to false + """Planet.zb_km, Planet.PbClathMax_MPa, Planet.PbIII_MPa, Planet.PbV_MPa, Planet.RaConvect, \ Planet.RaCrit, Planet.Tconv_K, Planet.TconvIII_K, Planet.TconvV_K, Planet.etaConv_Pas, \ Planet.etaConvIII_Pas, Planet.etaConvV_Pas, Planet.eLid_m, Planet.Dconv_m, Planet.deltaTBL_m, \ Planet.qCon_Wm2, Planet.qSurf_Wm2, Planet.TclathTrans_K, Planet.Ocean.QfromMantle_W \ - = (np.nan for _ in range(19)) + = (np.nan for _ in range(19))""" + + if Planet.zb_km == 0: + Planet.Pb_MPa = Planet.Bulk.Psurf_MPa + Planet.PbI_MPa = Planet.Bulk.Psurf_MPa + Planet.Bulk.Tb_K = Planet.Bulk.Tsurf_K + Planet.zb_km = 0.0 + Planet.Steps.nIceI = 0 + Planet.Steps.nIbottom = 0 + Planet.Steps.nSurfIce = 0 + Planet.Do.NO_ICE_CONVECTION = True + Planet.Do.CLATHRATE = False + log.debug('No ice shell present (zb_km = 0).') + return Planet, Params + else: + # Set up basic layer structure - each layer is treated as one discrete layer in array + Planet.Steps.nIbottom = Planet.Steps.nClath + Planet.Steps.nIceI + Planet.Steps.nIIIbottom = Planet.Steps.nIbottom + Planet.Steps.nIceIIILitho + Planet.Steps.nSurfIce = Planet.Steps.nIIIbottom + Planet.Steps.nIceVLitho + # Assign phase values for near-surface ices + Planet.phase[:Planet.Steps.nIbottom] = 1 # Ice Ih layers (some will be reassigned if Do.CLATHRATE = True) + Planet.phase[Planet.Steps.nIbottom:Planet.Steps.nIIIbottom] = 3 # Ice III layers + Planet.phase[Planet.Steps.nIIIbottom:Planet.Steps.nSurfIce] = 5 # Ice V layers + # Finally, we need to set up the logical array for ice convection layers (this will be default False and overriden if we do ice convection)) + Planet.Steps.iConv = np.zeros(Planet.Steps.nSurfIce, dtype=bool) + + # Propogate conduction layers + Planet = IceIWholeConductSolid(Planet, Params) + + # Calculate tempearture and thickness of convective sub-layer using Arrhenius law #TODO ask Flavio + if not Planet.Do.NO_ICE_CONVECTION: + Planet = IceIConvectSolid(Planet, Params) + + # Get the bottom pressure + Planet.PbI_MPa = Planet.P_MPa[Planet.Steps.nIbottom] + Planet.Pb_MPa = Planet.PbI_MPa + + + # Get heat flux out of the possibly convecting region + Planet.qCon_Wm2 = Planet.Ocean.QfromMantle_W / (4*np.pi * (Planet.Bulk.R_m - Planet.z_m[Planet.Steps.nSurfIce])**2) + # Get heat flux at the surface, assuming Htidal = Qrad = 0 throughout the entire hydrosphere. + Planet.qSurf_Wm2 = Planet.Ocean.QfromMantle_W / (4*np.pi * Planet.Bulk.R_m**2) - return Planet + + + log.info(f'Non-self-consistent ice shell thickness: {Planet.zb_km:.3f} km, ' + + f'transition pressure: {Planet.Pb_MPa:.3f} MPa.') + + return Planet, Params def IceIIIUnderplate(Planet, Params): @@ -515,22 +598,40 @@ def GetIceShellTFreeze(Planet, Params): # Deepcopy Params once to avoid repeatedly copying it inside the solver loop. Params_copy = deepcopy(Params) + # Create wrapper class that we can use for GetZero function that allows us to use zb_approximate_km tolerance + class IceShellResidual: + def __init__(self, Planet, Params, zb_target_km, zb_tol_km=1.0): + self.Planet = Planet + self.zb_target_km = zb_target_km + self.zb_tol_km = zb_tol_km + self.best_planet = None + self.best_T = None + self.last_residual = None - def iceShellTFreeze_solver(T, Planet_original): - """ - Computes the difference between target and actual ice shell thickness for a given temperature. - This function is designed to be called by a root-finding solver. - """ - # A deepcopy of the Planet object is necessary here because IceLayers modifies its state. - Planet_copy = deepcopy(Planet_original) - Planet_copy.Do.ICEIh_THICKNESS = False - Planet_copy.Bulk.Tb_K = T - result = IceLayers(Planet_copy, Params_copy) - - if result.PbI_MPa == 0.0: - raise ValueError('Ice layer computation failed: PbI_MPa == 0.0') - - return zb_approximate_km - result.zb_km + def __call__(self, T): + Planet_copy = deepcopy(self.Planet) + Planet_copy.Do.ICEIh_THICKNESS = False + Planet_copy.Bulk.Tb_K = T + + result = IceLayers(Planet_copy, Params_copy) + + if result.PbI_MPa == 0.0: + raise ValueError("Ice layer computation failed: PbI_MPa == 0.0") + + residual = self.zb_target_km - result.zb_km + abs_residual = abs(residual) + + # Save the best result + self.last_residual = abs_residual + self.best_planet = Planet_copy + self.best_planet.Do.ICEIh_THICKNESS = True + self.best_T = T + + # Early exit condition + if abs_residual < self.zb_tol_km: + raise StopIteration("Residual within tolerance") + + return residual # Part 1: Establish a valid temperature bracket try: @@ -541,31 +642,135 @@ def iceShellTFreeze_solver(T, Planet_original): log.debug(f"Established temperature bounds from phase diagram: [{TlowerLimit_K:.2f}, {TupperLimit_K:.2f}] K") except Exception as e: raise ValueError(f"Could not determine temperature bounds from phase diagram. Try lowering Planet.TfreezeLower_K, which represents the lower limit of the temperature search.") - + solver = IceShellResidual(Planet, Params, zb_approximate_km, zb_tol_km=0.01) # Find the precise freezing temperature using root finding try: - sol = GetZero(iceShellTFreeze_solver, - args=(Planet,), + sol = GetZero(solver, bracket=[TlowerLimit_K, TupperLimit_K], xtol=Planet.TfreezeRes_K) T_freeze = sol.root - log.debug(f"Computed ice shell freezing temperature: {T_freeze:.3f} K after {sol.function_calls} iterations.") - - return T_freeze - + return solver.best_planet + + except StopIteration: + log.debug("Stopped early because zb_km is within tolerance.") + return solver.best_planet except Exception as e: msg = '' if Planet.TfreezeLower_K < TlowerLimit_K + Planet.TfreezeRes_K: - msg += 'Try increasing Planet.PfreezeUpper_MPa.' + msg += 'Try increasing Planet.PfreezeUpper_MPa' else: - msg += 'Try decreasing Planet.TfreezeLower_K.' - raise ValueError(f"Root finding for corresponding Tb_K to achieve a given zb_approximate_km failed for temperature bracket [{TlowerLimit_K:.2f}, {TupperLimit_K:.2f}] K. {msg}") + msg += 'Try decreasing Planet.TfreezeLower_K' + raise ValueError(f"Root finding for corresponding Tb_K to achieve a given zb_approximate_km failed for temperature bracket [{TlowerLimit_K:.2f}, {TupperLimit_K:.2f}] K. {msg}. Also, the code is directly outptting the error: {e}") def OceanLayers(Planet, Params): + """ Wrapper function for ocean layer propogation. Decides between self consistent and non-self consistent modeling. + """ + Timing.setStartingTime(time.time()) + # Early branching for non-self-consistent modeling + if Planet.Do.NON_SELF_CONSISTENT: + Planet, Params = NonSelfConsistentOceanLayer(Planet, Params) + else: + Planet, Params = SelfConsistentOceanLayer(Planet, Params) + Timing.printFunctionTimeDifference('OceanLayers()', time.time()) + return Planet + + +def NonSelfConsistentOceanLayer(Planet, Params): + """ Non-self-consistent ocean layer modeling using specified mean properties + instead of detailed EOS calculations. Uses user-specified layer densities, + thicknesses, and thermal properties. + + Assigns Planet attributes: + phase, r_m, z_m, g_ms2, T_K, P_MPa, rho_kgm3, Cp_JkgK, alpha_pK, MLayer_kg + """ + if Planet.Do.VALID and not Planet.Do.NO_OCEAN: + log.debug('Evaluating non-self-consistent ocean layer.') + + + # Case is handled in SetupInit.py + if Planet.D_km == 0: + Planet.Steps.nHydro = Planet.Steps.nSurfIce + return Planet, Params + + else: + # Get number of ocean layers + nOcean = Planet.Steps.nHydro - Planet.Steps.nSurfIce + # Set up depth profile to bottom of hydrosphere + zOcean_m = np.linspace(Planet.zb_km * 1e3, (Planet.D_km + Planet.zb_km) * 1e3, nOcean + 1) + Planet.z_m[Planet.Steps.nSurfIce:Planet.Steps.nHydro + 1] = zOcean_m + # Get the oceanEOS + POcean_MPa = np.arange(Planet.Pb_MPa, Planet.Ocean.PHydroMax_MPa, Planet.Ocean.deltaP) + TOcean_K = np.arange(Planet.Bulk.Tb_K, Planet.Ocean.THydroMax_K, Planet.Ocean.deltaT) + Planet.Ocean.EOS = GetOceanEOS(Planet.Ocean.comp, Planet.Ocean.wOcean_ppt, POcean_MPa, TOcean_K, + Planet.Ocean.MgSO4elecType, rhoType=Planet.Ocean.MgSO4rhoType, + scalingType=Planet.Ocean.MgSO4scalingType, FORCE_NEW=Params.FORCE_EOS_RECALC, + phaseType=Planet.Ocean.phaseType, EXTRAP=Params.EXTRAP_OCEAN, + sigmaFixed_Sm=Planet.Ocean.sigmaFixed_Sm, LOOKUP_HIRES=Planet.Do.OCEAN_PHASE_HIRES, kThermConst_WmK=Planet.Ocean.kThermWater_WmK, + doConstantProps=Planet.Do.CONSTANTPROPSEOS, + constantProperties=Planet.Ocean.oceanConstantProperties, + propsStepReductionFactor=Planet.Ocean.propsStepReductionFactor) + # Log and propogate the first ocean layer separately - to prevent index issues + log.debug(f'il: {Planet.Steps.nSurfIce:d}; P_MPa: {Planet.P_MPa[Planet.Steps.nSurfIce]:.3f}; T_K: {Planet.T_K[Planet.Steps.nSurfIce]:.3f}; phase: {Planet.phase[Planet.Steps.nSurfIce]:d}') + Planet.rhoMatrix_kgm3[Planet.Steps.nSurfIce] = Planet.Ocean.EOS.fn_rho_kgm3(Planet.P_MPa[Planet.Steps.nSurfIce], Planet.T_K[Planet.Steps.nSurfIce]) + Planet.Cp_JkgK[Planet.Steps.nSurfIce] = Planet.Ocean.EOS.fn_Cp_JkgK(Planet.P_MPa[Planet.Steps.nSurfIce], Planet.T_K[Planet.Steps.nSurfIce]) + Planet.alpha_pK[Planet.Steps.nSurfIce] = Planet.Ocean.EOS.fn_alpha_pK(Planet.P_MPa[Planet.Steps.nSurfIce], Planet.T_K[Planet.Steps.nSurfIce]) + Planet.kTherm_WmK[Planet.Steps.nSurfIce] = Planet.Ocean.EOS.fn_kTherm_WmK(Planet.P_MPa[Planet.Steps.nSurfIce], Planet.T_K[Planet.Steps.nSurfIce]) + + # Propagate layer-top arrays + thisMAbove_kg = np.sum(Planet.MLayer_kg[:Planet.Steps.nSurfIce-1]) + # Get constant gravity if we will be assigning it + if Planet.Do.CONSTANT_GRAVITY: + Planet.g_ms2[Planet.Steps.nSurfIce+1:] = Constants.G * (Planet.Bulk.M_kg - thisMAbove_kg) / Planet.r_m[Planet.Steps.nSurfIce-1]**2 + else: + # Ensure g values to be assigned are zero since we will be adding to them + Planet.g_ms2[Planet.Steps.nSurfIce+1:] = 0 + # Assign 0 or 1 multiplier for constant/variable gravity calcs in loop + VAR_GRAV = int(not Planet.Do.CONSTANT_GRAVITY) + + for i in range(Planet.Steps.nSurfIce+1, Planet.Steps.nHydro+1): + # Increment depth based on change in pressure, combined with gravity and density + Planet.P_MPa[i] = Planet.P_MPa[i-1] + Planet.rhoMatrix_kgm3[i-1] * Planet.g_ms2[i-1] * (Planet.z_m[i] - Planet.z_m[i-1]) * 1e-6 + # Convert depth to radius + Planet.r_m[i] = Planet.Bulk.R_m - Planet.z_m[i] + # Calculate layer mass + Planet.MLayer_kg[i-1] = 4/3*np.pi * Planet.rhoMatrix_kgm3[i-1] * (Planet.r_m[i-1] ** 3 - Planet.r_m[i] ** 3) + thisMAbove_kg += Planet.MLayer_kg[i-1] + thisMBelow_kg = Planet.Bulk.M_kg - thisMAbove_kg + # Use remaining mass below in Gauss's law for gravity to get g at the top of this layer + Planet.g_ms2[i] += VAR_GRAV * Constants.G * thisMBelow_kg / Planet.r_m[i] ** 2 + # Now use the present layer's properties to calculate an adiabatic thermal profile for layers below + Planet.T_K[i] = Planet.T_K[i-1] + Planet.T_K[i-1] * Planet.alpha_pK[i-1] / \ + Planet.Cp_JkgK[i-1] / Planet.rhoMatrix_kgm3[i-1] * ((Planet.P_MPa[i] - Planet.P_MPa[i-1]) * 1e6) + # Calculate this layer's properties + if i < Planet.Steps.nHydro: + Planet.rhoMatrix_kgm3[i] = Planet.Ocean.EOS.fn_rho_kgm3(Planet.P_MPa[i], Planet.T_K[i]) + Planet.Cp_JkgK[i] = Planet.Ocean.EOS.fn_Cp_JkgK(Planet.P_MPa[i], Planet.T_K[i]) + Planet.alpha_pK[i] = Planet.Ocean.EOS.fn_alpha_pK(Planet.P_MPa[i], Planet.T_K[i]) + Planet.kTherm_WmK[i] = Planet.Ocean.EOS.fn_kTherm_WmK(Planet.P_MPa[i], Planet.T_K[i]) + if i == Planet.Steps.nHydro: + log.debug(f'Propogating starting point for next layer...') + log.debug(f'il: {i:d}; P_MPa: {Planet.P_MPa[i]:.3f}; T_K: {Planet.T_K[i]:.3f}') + else: + log.debug(f'il: {i:d}; P_MPa: {Planet.P_MPa[i]:.3f}; T_K: {Planet.T_K[i]:.3f}; phase: {Planet.phase[i]:d}') + + # Fill in properties + Planet.rho_kgm3[Planet.Steps.nSurfIce:Planet.Steps.nHydro+1] = Planet.rhoMatrix_kgm3[Planet.Steps.nSurfIce:Planet.Steps.nHydro+1] + + + + + log.info(f'Ocean layers complete. zMax: {Planet.z_m[Planet.Steps.nHydro]/1e3:.1f} km, ' + + f'upper ice thickness zb: {Planet.zb_km:.3f} km') + + + return Planet, Params + + +def SelfConsistentOceanLayer(Planet, Params): """ Geophysical and thermodynamic calculations for ocean layer Calculates state variables of the layer with each pressure step @@ -600,8 +805,9 @@ def OceanLayers(Planet, Params): TOcean_K = np.insert(TOcean_K, 0, Planet.T_K[Planet.Steps.nSurfIce]) # Add HP ices to iceEOS if needed - PHydroMax_MPa = np.maximum(Planet.Ocean.PHydroMax_MPa, Planet.Sil.PHydroMax_MPa) - if PHydroMax_MPa > Planet.Ocean.PHydroMax_MPa: + PHydroMax_MPa = Planet.Ocean.PHydroMax_MPa + if Planet.Do.POROUS_ROCK and Planet.Sil.PHydroMax_MPa > PHydroMax_MPa: + PHydroMax_MPa = Planet.Sil.PHydroMax_MPa PHPices_MPa = np.arange(POcean_MPa[0], PHydroMax_MPa, Planet.Ocean.deltaP) else: PHPices_MPa = POcean_MPa @@ -612,19 +818,23 @@ def OceanLayers(Planet, Params): if Planet.Do.NO_OCEAN_EXCEPT_INNER_ICES: initialPOcean_MPa = POcean_MPa[0] thisPhase = Planet.Ocean.EOS.fn_phase(initialPOcean_MPa, TOcean_K[0]).astype(np.int_) + # Due to numerical approximation with GetZero function, thisPhase can be an ice Ih or ocean layer if thisPhase < 2: - log.warning(f'The first calculated phase is not a high pressure ice layer. \n' + + # Depending on the root answer that fn_phase finds, thisPhase can be an ocean layer. So we must get the ice phase by incrementing P by deltaP so we are on the high pressure side of the phase diagram + thisPhase = Planet.Ocean.EOS.fn_phase(initialPOcean_MPa+Planet.Ocean.EOS.deltaP, TOcean_K[0]).astype(np.int_) + # Past error debugging that is too complicated. We have simplified + """log.warning(f'The first calculated phase is not a high pressure ice layer. \n' + f'When Planet.Do.NO_OCEAN_EXCEPT_INNER_ICES is True, the layers below the initial ice propogation should be high pressure ices.\n' + f' T will be set to be lower than the melting temp temporarily and P will be set slightly higher than the melting pressure to construct the first high pressure ice layer.') # Increase P by deltaP temporarily so we can move into the next 'layer' of phase diagram initialPOcean_MPa += Planet.Ocean.deltaP # Get the freezing temperature and decrease by TfreezeOffset_K to ensure we stay within the high pressure phase diagram - TOcean_K[0] = GetTfreeze(Planet.Ocean.EOS, initialPOcean_MPa, TOcean_K[0], TRes_K=0) - Planet.Ocean.TfreezeOffset_K + TOcean_K[0] = GetTfreeze(Planet.Ocean.EOS, initialPOcean_MPa, TOcean_K[0], TRes_K=Planet.Ocean.TfreezeOffset_K) - Planet.Ocean.TfreezeOffset_K thisPhase = Planet.Ocean.EOS.fn_phase(initialPOcean_MPa, TOcean_K[0]).astype(np.int_) if thisPhase == 0: - raise ValueError('Even after slightly increasing P and slightly decreasingT, the first phase is still a liquid. \n' + + raise ValueError('Even after slightly increasing P and slightly decreasing T, the first phase is still a liquid. \n' + f'Generating an ocean is not desired when Planet.Do.NO_OCEAN_EXCEPT_INNER_ICES is True. \n' + - f'Please check your Planet.Bulk.Tb_K is set correctly for high pressure ices to form. Namely, try decreasing it further.') + f'Please check your Planet.Bulk.Tb_K is set correctly for high pressure ices to form. Namely, try decreasing it further.')""" Planet.phase[Planet.Steps.nSurfIce] = thisPhase log.debug(f'il: {Planet.Steps.nSurfIce:d}; P_MPa: {POcean_MPa[0]:.3f}; ' + f'T_K: {TOcean_K[0]:.3f}; phase: {Planet.phase[Planet.Steps.nSurfIce]:d}') @@ -696,17 +906,18 @@ def OceanLayers(Planet, Params): TOcean_K[1] = TOcean_K[0] + alphaOcean_pK[0] * TOcean_K[0] / \ CpOcean_JkgK[0] / rhoOcean_kgm3[0] * Planet.Ocean.deltaP*1e6 iStart = 1 - for i in range(iStart, Planet.Steps.nOceanMax): Planet.phase[Planet.Steps.nSurfIce+i] = Planet.Ocean.EOS.fn_phase(POcean_MPa[i], TOcean_K[i]).astype(np.int_) if not Planet.Do.NO_OCEAN_EXCEPT_INNER_ICES and i < 4 and Planet.phase[Planet.Steps.nSurfIce+i] != 0: log.debug(f'Top ocean layers (i={i}) are not liquid. This will cause indexing problems. ' + 'T will be set to exceed the melting temp temporarily to construct at least 4 ocean layers.') Planet.THIN_OCEAN = True - TOcean_K[i] = GetTfreeze(Planet.Ocean.EOS, POcean_MPa[i], TOcean_K[i]) + Planet.Ocean.TfreezeOffset_K + TOcean_K[i] = GetTfreeze(Planet.Ocean.EOS, POcean_MPa[i], TOcean_K[i], TRes_K = 0.00001) Planet.phase[Planet.Steps.nSurfIce+i] = 0 log.debug(f'il: {Planet.Steps.nSurfIce+i:d}; P_MPa: {POcean_MPa[i]:.3f}; ' + f'T_K: {TOcean_K[i]:.3f}; phase: {Planet.phase[Planet.Steps.nSurfIce+i]:d}') + if Planet.phase[Planet.Steps.nSurfIce+i] == 0 and Planet.phase[Planet.Steps.nSurfIce+i-1] > 2: + log.warning(f'Ocean layer {i} is a liquid layer, but the previous layer is a high pressure ice layer. ') if Planet.phase[Planet.Steps.nSurfIce+i] < 2: # Liquid water layers -- get fluid properties for the present layer but with the # overlaying layer's temperature. Note that we include ice Ih in these layers because @@ -726,15 +937,14 @@ def OceanLayers(Planet, Params): # This is based on an assumption that the undersea HP ices are vigorously mixed by # two-phase convection, such that each layer is in local equilibrium with the liquid, # meaning each layer's temperature is equal to the melting temperature. - # We implement this by averaging the upper layer temp with the melting temp minus a small offset, + # We implement this by averaging the upper layer temp with the melting temp minus two times the deltaT of the EOS, which ensures we are on the solid side of the phase diagram # to step more gently and avoid overshooting that causes phase oscillations. thisPhase = PhaseConv(Planet.phase[Planet.Steps.nSurfIce+i]) rhoOcean_kgm3[i] = Planet.Ocean.iceEOS[thisPhase].fn_rho_kgm3(POcean_MPa[i], TOcean_K[i]) CpOcean_JkgK[i] = Planet.Ocean.iceEOS[thisPhase].fn_Cp_JkgK(POcean_MPa[i], TOcean_K[i]) alphaOcean_pK[i] = Planet.Ocean.iceEOS[thisPhase].fn_alpha_pK(POcean_MPa[i], TOcean_K[i]) kThermOcean_WmK[i] = Planet.Ocean.iceEOS[thisPhase].fn_kTherm_WmK(POcean_MPa[i], TOcean_K[i]) - TOcean_K[i+1] = np.mean([GetTfreeze(Planet.Ocean.EOS, POcean_MPa[i], TOcean_K[i]) - - Planet.Ocean.TfreezeOffset_K, TOcean_K[i]]) + TOcean_K[i+1] = np.mean([GetTfreeze(Planet.Ocean.EOS, POcean_MPa[i], TOcean_K[i], TRes_K=Planet.Ocean.EOS.deltaT) - Planet.Ocean.EOS.deltaT*2, TOcean_K[i]]) # Assign ocean layer critical properties to Planet fields Planet.P_MPa[Planet.Steps.nSurfIce:Planet.Steps.nSurfIce + Planet.Steps.nOceanMax] = POcean_MPa @@ -803,7 +1013,7 @@ def OceanLayers(Planet, Params): log.info(f'Ocean layers complete. zMax: {Planet.z_m[Planet.Steps.nSurfIce + Planet.Steps.nOceanMax - 1]/1e3:.1f} km, ' + f'upper ice thickness zb: {Planet.zb_km:.3f} km{zClathInfo}') - return Planet + return Planet, Params def GetOceanHPIceEOS(Planet, Params, POcean_MPa, minPres_MPa=None, minTres_K=None): @@ -825,32 +1035,32 @@ def GetOceanHPIceEOS(Planet, Params, POcean_MPa, minPres_MPa=None, minTres_K=Non # Stopgap measure to avoid MgSO4 calcs taking ages with the slow Margules formulation phase calcs # Remove this if/else block (just do the "else") when a faster phase calculation is implemented! - if Planet.Ocean.comp == 'MgSO4' or Planet.Sil.poreComp == 'MgSO4': + if (Planet.Ocean.comp == 'MgSO4' or Planet.Sil.poreComp == 'MgSO4') and Planet.Ocean.phaseType == 'calc': # Just load all HP ice phases in case we need them. This part is way faster than Margules phase calcs Planet.Ocean.iceEOS['II'] = GetIceEOS(POceanHPices_MPa, TOceanHPices_K, 'II', porosType=Planet.Ocean.porosType['II'], phiTop_frac=Planet.Ocean.phiMax_frac['II'], Pclosure_MPa=Planet.Ocean.Pclosure_MPa['II'], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE['II'], - minPres_MPa=minPres_MPa, minTres_K=minTres_K) + minPres_MPa=minPres_MPa, minTres_K=minTres_K, kThermConst_WmK=Planet.Ocean.kThermIce_WmK) Planet.Ocean.iceEOS['III'] = GetIceEOS(POceanHPices_MPa, TOceanHPices_K, 'III', porosType=Planet.Ocean.porosType['III'], phiTop_frac=Planet.Ocean.phiMax_frac['III'], Pclosure_MPa=Planet.Ocean.Pclosure_MPa['III'], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE['III'], - minPres_MPa=minPres_MPa, minTres_K=minTres_K) + minPres_MPa=minPres_MPa, minTres_K=minTres_K, kThermConst_WmK=Planet.Ocean.kThermIce_WmK) Planet.Ocean.iceEOS['V'] = GetIceEOS(POceanHPices_MPa, TOceanHPices_K, 'V', porosType=Planet.Ocean.porosType['V'], phiTop_frac=Planet.Ocean.phiMax_frac['V'], Pclosure_MPa=Planet.Ocean.Pclosure_MPa['V'], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE['V'], - minPres_MPa=minPres_MPa, minTres_K=minTres_K) + minPres_MPa=minPres_MPa, minTres_K=minTres_K, kThermConst_WmK=Planet.Ocean.kThermIce_WmK) Planet.Ocean.iceEOS['VI'] = GetIceEOS(POceanHPices_MPa, TOceanHPices_K, 'VI', porosType=Planet.Ocean.porosType['VI'], phiTop_frac=Planet.Ocean.phiMax_frac['VI'], Pclosure_MPa=Planet.Ocean.Pclosure_MPa['VI'], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE['VI'], - minPres_MPa=minPres_MPa, minTres_K=minTres_K) + minPres_MPa=minPres_MPa, minTres_K=minTres_K, kThermConst_WmK=Planet.Ocean.kThermIce_WmK) else: # Get phase of each P,T combination expandPhases = Planet.Ocean.EOS.fn_phase(POceanHPices_MPa, TOceanHPices_K, grid = True).flatten() @@ -872,7 +1082,7 @@ def GetOceanHPIceEOS(Planet, Params, POcean_MPa, minPres_MPa=None, minTres_K=Non phiTop_frac=Planet.Ocean.phiMax_frac['II'], Pclosure_MPa=Planet.Ocean.Pclosure_MPa['II'], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE['II'], - minPres_MPa=minPres_MPa, minTres_K=minTres_K) + minPres_MPa=minPres_MPa, minTres_K=minTres_K, kThermConst_WmK=Planet.Ocean.kThermIce_WmK) if(np.size(indsIceIII) != 0): log.debug('Loading ice III EOS functions for ocean layers...') PiceIIImin_MPa = PHPicesLin_MPa[indsIceIII[0]] @@ -885,7 +1095,7 @@ def GetOceanHPIceEOS(Planet, Params, POcean_MPa, minPres_MPa=None, minTres_K=Non phiTop_frac=Planet.Ocean.phiMax_frac['III'], Pclosure_MPa=Planet.Ocean.Pclosure_MPa['III'], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE['III'], - minPres_MPa=minPres_MPa, minTres_K=minTres_K) + minPres_MPa=minPres_MPa, minTres_K=minTres_K, kThermConst_WmK=Planet.Ocean.kThermIce_WmK) if(np.size(indsIceV) != 0): log.debug('Loading ice V EOS functions for ocean layers...') PiceVmin_MPa = PHPicesLin_MPa[indsIceV[0]] @@ -898,7 +1108,7 @@ def GetOceanHPIceEOS(Planet, Params, POcean_MPa, minPres_MPa=None, minTres_K=Non phiTop_frac=Planet.Ocean.phiMax_frac['V'], Pclosure_MPa=Planet.Ocean.Pclosure_MPa['V'], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE['V'], - minPres_MPa=minPres_MPa, minTres_K=minTres_K) + minPres_MPa=minPres_MPa, minTres_K=minTres_K, kThermConst_WmK=Planet.Ocean.kThermIce_WmK) if(np.size(indsIceVI) != 0): log.debug('Loading ice VI EOS functions for ocean layers...') PiceVImin_MPa = PHPicesLin_MPa[indsIceVI[0]] @@ -908,10 +1118,10 @@ def GetOceanHPIceEOS(Planet, Params, POcean_MPa, minPres_MPa=None, minTres_K=Non Planet.Ocean.iceEOS['VI'] = GetIceEOS(np.linspace(PiceVImin_MPa, PiceVImax_MPa, Planet.Steps.nPsHP), np.linspace(TiceVImin_K, TiceVImax_K, Planet.Steps.nTsHP), 'VI', porosType=Planet.Ocean.porosType['VI'], - phiTop_frac=Planet.Ocean.phiMax_frac['VI'], + phiTop_frac=Planet.Ocean.phiMax_frac['VI'], Pclosure_MPa=Planet.Ocean.Pclosure_MPa['VI'], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE['VI'], - minPres_MPa=minPres_MPa, minTres_K=minTres_K) + minPres_MPa=minPres_MPa, minTres_K=minTres_K, kThermConst_WmK=Planet.Ocean.kThermIce_WmK) else: log.debug('No high-pressure ices found in ocean layers.') @@ -919,6 +1129,18 @@ def GetOceanHPIceEOS(Planet, Params, POcean_MPa, minPres_MPa=None, minTres_K=Non def InnerLayers(Planet, Params): + """ Wrapper function for inner layer propogation. Decides between self consistent and non-self consistent modeling. + """ + Timing.setFunctionTime(time.time()) + # Early branching for non-self-consistent modeling + if Planet.Do.NON_SELF_CONSISTENT: + Planet, Params = NonSelfConsistentInnerLayer(Planet, Params) + else: + Planet, Params = SelfConsistentInnerLayer(Planet, Params) + Timing.printFunctionTimeDifference('InnerLayers()', time.time()) + return Planet + +def SelfConsistentInnerLayer(Planet, Params): """ Geophysical and thermodynamic calculations for silicate and core layers Calculates state variables of the layer with each pressure step @@ -932,12 +1154,20 @@ def InnerLayers(Planet, Params): kThermConst_WmK=Planet.Sil.kTherm_WmK, HtidalConst_Wm3=Planet.Sil.Htidal_Wm3, porosType=Planet.Sil.porosType, phiTop_frac=Planet.Sil.phiRockMax_frac, Pclosure_MPa=Planet.Sil.Pclosure_MPa, phiMin_frac=Planet.Sil.phiMin_frac, - EXTRAP=Params.EXTRAP_SIL) + EXTRAP=Params.EXTRAP_SIL, etaSilFixed_Pas=Planet.Sil.etaRock_Pas, etaCoreFixed_Pas=[Planet.Core.etaFeSolid_Pas, Planet.Core.etaFeLiquid_Pas], + TviscTrans_K=Planet.Sil.TviscTrans_K, + doConstantProps=Planet.Do.CONSTANT_INNER_DENSITY, constantProperties={'rho_kgm3': Planet.Sil.rhoSilWithCore_kgm3, 'Cp_JkgK': np.nan, 'alpha_pK': np.nan, 'kTherm_WmK': Planet.Sil.kTherm_WmK, + 'VP_kms': Planet.Sil.VPset_kms, 'VS_kms': Planet.Sil.VSset_kms, 'KS_GPa': Planet.Sil.KSset_GPa, 'GS_GPa': Planet.Sil.GSset_GPa, 'eta_Pas': Planet.Sil.etaRock_Pas, + 'sigma_Sm': Planet.Sil.sigmaSil_Sm}) # Iron core if present if not Params.SKIP_INNER and Planet.Do.Fe_CORE and Planet.Core.EOS.key not in EOSlist.loaded.keys(): Planet.Core.EOS = GetInnerEOS(Planet.Core.coreEOS, EOSinterpMethod=Params.lookupInterpMethod, Fe_EOS=True, kThermConst_WmK=Planet.Core.kTherm_WmK, EXTRAP=Params.EXTRAP_Fe, - wFeCore_ppt=Planet.Core.wFe_ppt, wScore_ppt=Planet.Core.wS_ppt) + wFeCore_ppt=Planet.Core.wFe_ppt, wScore_ppt=Planet.Core.wS_ppt, etaSilFixed_Pas=Planet.Sil.etaRock_Pas, etaCoreFixed_Pas=[Planet.Core.etaFeSolid_Pas, Planet.Core.etaFeLiquid_Pas], + TviscTrans_K=Planet.Core.TviscTrans_K, + doConstantProps=True, constantProperties={'rho_kgm3': Planet.Core.rhoFe_kgm3, 'Cp_JkgK': np.nan, 'alpha_pK': np.nan, 'kTherm_WmK': Planet.Core.kTherm_WmK, + 'VP_kms': np.nan, 'VS_kms': np.nan, 'KS_GPa': np.nan, 'GS_GPa': Planet.Core.GSset_GPa, 'eta_Pas': Planet.Core.etaFeSolid_Pas, + 'sigma_Sm': Planet.Core.sigmaCore_Sm}) # Pore fluids if present if not Params.SKIP_INNER and Planet.Do.POROUS_ROCK and Planet.Sil.poreEOS.key not in EOSlist.loaded.keys(): @@ -954,7 +1184,9 @@ def InnerLayers(Planet, Params): Planet.Ocean.MgSO4elecType, rhoType=Planet.Ocean.MgSO4rhoType, scalingType=Planet.Ocean.MgSO4scalingType, FORCE_NEW=Params.FORCE_EOS_RECALC, phaseType=Planet.Ocean.phaseType, EXTRAP=Params.EXTRAP_OCEAN, PORE=True, - sigmaFixed_Sm=Planet.Sil.sigmaPoreFixed_Sm) + sigmaFixed_Sm=Planet.Sil.sigmaPoreFixed_Sm, kThermConst_WmK=Planet.Ocean.kThermWater_WmK, + propsStepReductionFactor=Planet.Ocean.propsStepReductionFactor) + # Make sure Sil.phiRockMax_frac is set in case we're using a porosType that doesn't require it if Planet.Sil.phiRockMax_frac is None or Planet.Sil.porosType != 'Han2014': @@ -1000,7 +1232,7 @@ def InnerLayers(Planet, Params): = mantleProps iCC = Planet.Steps.nTotal - if Planet.Do.Fe_CORE: + if Planet.Do.Fe_CORE and iCC > iSC: # Unpack results from MoI calculations Planet.P_MPa[iSC:iCC], Planet.T_K[iSC:iCC], Planet.r_m[iSC:iCC], Planet.g_ms2[iSC:iCC], Planet.rho_kgm3[iSC:iCC], \ Planet.Cp_JkgK[iSC:iCC], Planet.alpha_pK[iSC:iCC], Planet.kTherm_WmK[iSC:iCC], Planet.MLayer_kg[iSC:iCC] \ @@ -1107,9 +1339,202 @@ def InnerLayers(Planet, Params): Planet.Steps.nSil = 0 Planet.Steps.nTotal = 1 - return Planet + return Planet, Params +def NonSelfConsistentInnerLayer(Planet, Params): + """ Non-self-consistent inner layer modeling using specified mean properties + instead of detailed EOS calculations. Uses user-specified layer densities, + radii, and thermal properties. + + Only setup for basic silicate and core layers at the moment + Assigns Planet attributes: + Steps.nTotal, Sil.Rmean_m, Core.Rmean_m, Sil.rhoMean_kgm3, Core.rhoMean_kgm3, + phase, r_m, z_m, g_ms2, T_K, P_MPa, rho_kgm3, MLayer_kg + """ + log.debug('Evaluating non-self-consistent inner layer.') + + if Planet.Do.PARTIAL_DIFFERENTIATION or Planet.Do.NO_DIFFERENTIATION: #TODO: Revisit in future + raise ValueError('Non-self-consistent inner layer modeling is not supported for partial or no differentiation modeling at this moment.') + + if Planet.Do.VALID: + # Add final end layers to r_m and z_m so we can calculate masses + Planet.r_m = np.append(Planet.r_m, [0]) + Planet.z_m = np.append(Planet.z_m, Planet.r_m[0]) + # Calculate position of top of silicate layer + Planet.Sil.Rmean_m = Planet.Bulk.R_m - Planet.zb_km * 1000 - Planet.D_km * 1000 + dzSil_m = Planet.Sil.Rmean_m - Planet.Core.Rmean_m + # Check if we are modeling a silicate layer + if Planet.Sil.Rmean_m <= 0: + Planet.Steps.nSil = 0 + pass + else: + # Set up derived layer arrays + # NOTE how we don't include the 'next' layer in the derived arrays for the inner layers in order to prevent out of bounds indexing + Planet.phase[Planet.Steps.nHydro:Planet.Steps.nHydro + Planet.Steps.nSil] = Constants.phaseSil + Planet.z_m[Planet.Steps.nHydro:Planet.Steps.nHydro + Planet.Steps.nSil+1] = np.linspace(Planet.z_m[Planet.Steps.nHydro], Planet.z_m[Planet.Steps.nHydro] + dzSil_m, Planet.Steps.nSil+1) + + Planet.Sil.EOS = GetInnerEOS(Planet.Sil.mantleEOS, EOSinterpMethod=Params.lookupInterpMethod, + kThermConst_WmK=Planet.Sil.kTherm_WmK, HtidalConst_Wm3=Planet.Sil.Htidal_Wm3, + porosType=Planet.Sil.porosType, phiTop_frac=Planet.Sil.phiRockMax_frac, + Pclosure_MPa=Planet.Sil.Pclosure_MPa, phiMin_frac=Planet.Sil.phiMin_frac, + EXTRAP=Params.EXTRAP_SIL, etaSilFixed_Pas=Planet.Sil.etaRock_Pas, etaCoreFixed_Pas=[Planet.Core.etaFeSolid_Pas, Planet.Core.etaFeLiquid_Pas], + TviscTrans_K=Planet.Sil.TviscTrans_K, + doConstantProps=True, constantProperties={'rho_kgm3': Planet.Sil.rhoSilWithCore_kgm3, 'Cp_JkgK': np.nan, 'alpha_pK': np.nan, 'kTherm_WmK': Planet.Sil.kTherm_WmK, + 'VP_kms': Planet.Sil.VPset_kms, 'VS_kms': Planet.Sil.VSset_kms, 'KS_GPa': Planet.Sil.KSset_GPa, 'GS_GPa': Planet.Sil.GSset_GPa, 'eta_Pas': Planet.Sil.etaRock_Pas, + 'sigma_Sm': Planet.Sil.sigmaSil_Sm}) + + # Propogate conduction + Tbot_K = Planet.T_K[Planet.Steps.nHydro] #TODO Fix this to make it self consistent + Planet = PropogateConductionFromDepth(Planet, Params, Planet.Steps.nHydro, Planet.Steps.nHydro + Planet.Steps.nSil, Tbot_K, Planet.Sil.EOS, propogateNextLayer=Planet.Do.Fe_CORE) #TODO Shouldn't use this equation to calculate tempearture + if not Planet.Do.Fe_CORE: + Planet.Steps.nCore = 0 + # Set up core layer + else: + # Set up derived layer arrays + # NOTE how we have to reset + startCore = Planet.Steps.nHydro + Planet.Steps.nSil + Planet.phase[startCore:startCore + Planet.Steps.nCore] = Constants.phaseFe + Planet.z_m[startCore:startCore + Planet.Steps.nCore+1] = np.linspace(Planet.z_m[startCore-1]+Planet.Sil.Rmean_m, Planet.z_m[startCore-1]+Planet.Sil.Rmean_m+Planet.Core.Rmean_m, Planet.Steps.nCore+1) + + if Planet.Core.rhoMean_kgm3 is None: + Planet.Core.rhoMean_kgm3 = Planet.Core.rhoFe_kgm3 + log.warning('Planet.Core.rhoMean_kgm3 is not set, using Planet.Core.rhoFe_kgm3.') + if Planet.Core.kTherm_WmK is None: + Planet.Core.kTherm_WmK = Constants.kThermFe_WmK + log.warning('Planet.Core.kTherm_WmK is not set, using Constants.kThermFe_WmK.') + # Set up derived layer arrays + Planet.Core.EOS = GetInnerEOS(Planet.Core.coreEOS, EOSinterpMethod=Params.lookupInterpMethod, Fe_EOS=True, + kThermConst_WmK=Planet.Core.kTherm_WmK, EXTRAP=Params.EXTRAP_Fe, + wFeCore_ppt=Planet.Core.wFe_ppt, wScore_ppt=Planet.Core.wS_ppt, etaSilFixed_Pas=Planet.Sil.etaRock_Pas, etaCoreFixed_Pas=[Planet.Core.etaFeSolid_Pas, Planet.Core.etaFeLiquid_Pas], + TviscTrans_K=Planet.Core.TviscTrans_K, + doConstantProps=True, constantProperties={'rho_kgm3': Planet.Core.rhoFe_kgm3, 'Cp_JkgK': np.nan, 'alpha_pK': np.nan, 'kTherm_WmK': Planet.Core.kTherm_WmK, + 'VP_kms': np.nan, 'VS_kms': np.nan, 'KS_GPa': np.nan, 'GS_GPa': Planet.Core.GSset_GPa, 'eta_Pas': Planet.Core.etaFeSolid_Pas, + 'sigma_Sm': Planet.Core.sigmaCore_Sm}) + + # Propogate conduction + Tbot_K = Planet.T_K[startCore-1] #TODO Fix this to make it self consistent + Planet = PropogateConductionFromDepth(Planet, Params, startCore, startCore + Planet.Steps.nCore, Tbot_K, Planet.Core.EOS, propogateNextLayer=False) + # Calculate the mass of the final layer + Planet.MLayer_kg[-1] = 4/3*np.pi * (Planet.r_m[-2]**3 - Planet.r_m[-1]**3) * Planet.rho_kgm3[-1] + + # Calculate total mass and CMR2 + Mtot_kg = np.sum(Planet.MLayer_kg) + MR2_kgm2 = Planet.Bulk.M_kg * Planet.Bulk.R_m**2 + C_kgm2 = np.sum(8*np.pi/15 * Planet.rho_kgm3 * (Planet.r_m[:-1]**5 - Planet.r_m[1:]**5)) + CMR2 = C_kgm2 / MR2_kgm2 + + # Calculate differences in mass and CMR2 + Mdiff_frac = np.abs(1 - Mtot_kg / Planet.Bulk.M_kg) + CMR2diff_upper = Planet.Bulk.Cmeasured + Planet.Bulk.CuncertaintyUpper + CMR2diff_lower = Planet.Bulk.Cmeasured - Planet.Bulk.CuncertaintyLower + MdiffThresh = 0.05 + if Mdiff_frac > MdiffThresh: + if Mtot_kg - Planet.Bulk.M_kg > 0: + Planet.Do.VALID = False + Planet.invalidReason = f'Mass of planet is more than {100 * MdiffThresh:g}% more than the total body mass.' + invalidMessage = Planet.invalidReason + ' Try lowering the density of layers or increasing the ice thickness, which should have the lowest density.' + else: + Planet.Do.VALID = False + Planet.invalidReason = f'Mass of planet is more than {100 * MdiffThresh:g}% less than the total body mass.' + invalidMessage = Planet.invalidReason + ' Try increasing the density of layers or increasing the inner layers thickness, which should have the highest density.' + if Params.ALLOW_BROKEN_MODELS: + if Params.DO_EXPLOREOGRAM or Params.DO_INDUCTOGRAM or Params.DO_MONTECARLO: + log.info(invalidMessage + ' Params.ALLOW_BROKEN_MODELS is True, so calculations will proceed ' + + 'with many values set to nan.') + else: + log.error(invalidMessage + ' Params.ALLOW_BROKEN_MODELS is True, so calculations will proceed ' + + 'with many values set to nan.') + Planet.Do.STILL_CALCULATE_BROKEN_PROPERTIES = True + else: + raise RuntimeError(invalidMessage) + elif CMR2diff_upper > CMR2 or CMR2 < CMR2diff_lower: + if CMR2diff_upper > CMR2: + Planet.Do.VALID = False + Planet.invalidReason = f'CMR2 of planet is more than {100 * CMR2diff_upper:.1f}% more than the measured value.' + invalidMessage = Planet.invalidReason + ' Try lowering the density of layers or increasing the inner layers thickness, which should have the highest density.' + else: + Planet.Do.VALID = False + Planet.invalidReason = f'CMR2 of planet is less than {100 * CMR2diff_lower:.1f}% less than the measured value.' + invalidMessage = Planet.invalidReason + ' Try increasing the density of layers or increasing the inner layers thickness, which should have the highest density.' + if Params.ALLOW_BROKEN_MODELS: + if Params.DO_EXPLOREOGRAM or Params.DO_INDUCTOGRAM or Params.DO_MONTECARLO: + log.info(invalidMessage + ' Params.ALLOW_BROKEN_MODELS is True, so calculations will proceed ' + + 'with many values set to nan.') + else: + log.error(invalidMessage + ' Params.ALLOW_BROKEN_MODELS is True, so calculations will proceed ' + + 'with many values set to nan.') + Planet.Do.STILL_CALCULATE_BROKEN_PROPERTIES = True + else: + raise RuntimeError(invalidMessage) + if not Planet.Do.VALID and not Params.ALLOW_BROKEN_MODELS: + Planet.CMR2mean = np.nan + Planet.CMR2less = Planet.CMR2mean + Planet.CMR2more = Planet.CMR2mean + + else: + Planet.CMR2mean = CMR2 + Planet.CMR2less = CMR2 + Planet.CMR2more = CMR2 + nans = np.array([np.nan]) + Planet.Sil.rhoTrade_kgm3 = nans + Planet.Sil.Rtrade_m = nans + Planet.Sil.Rrange_m = np.nan + Planet.Core.Rtrade_m = nans + Planet.Core.Rrange_m = np.nan + + + # Simplified mass calculations for compatibility + Planet.Mtot_kg = Mtot_kg + Planet.Mice_kg = np.sum(Planet.MLayer_kg[:Planet.Steps.nSurfIce]) + Planet.Mcore_kg = np.sum(Planet.MLayer_kg[Planet.Steps.nHydro + Planet.Steps.nSil:]) + Planet.Mrock_kg = np.sum(Planet.MLayer_kg[Planet.Steps.nHydro:Planet.Steps.nHydro + Planet.Steps.nSil]) + Planet.Mfluid_kg = Planet.Mtot_kg - Planet.Mcore_kg - Planet.Mrock_kg - Planet.Mice_kg + #Planet.Mclath_kg = 0.0 + #Planet.MclathGas_kg = 0.0 + #Planet.Mocean_kg = Planet.Mfluid_kg + #Planet.MporeFluid_kg = 0.0 + #Planet.MporeSalt_kg = 0.0 + #Planet.Msalt_kg = 0.0 + Planet.Mocean_kg = np.sum(Planet.MLayer_kg[Planet.phase == 0]) + Planet.MporeFluid_kg = Planet.Mfluid_kg - Planet.Mocean_kg + #Planet.Mclath_kg = 0.0 + #Planet.MclathGas_kg = 0.0 + #Planet.MoceanSalt_kg = Planet.Mfluid_kg - Planet.MporeFluid_kg + #Planet.MporeSalt_kg = Planet.Mfluid_kg - Planet.MporeFluid_kg + #Planet.Msalt_kg = Planet.Mfluid_kg - Planet.MporeFluid_kg + #Planet.MH2O_kg = Planet.Mfluid_kg + #Planet.Mtot_kg = Planet.Mcore_kg + Planet.Mrock_kg + Planet.Mfluid_kg + + # Set remaining mean properties + Planet.Ocean.Tmean_K = Planet.Bulk.Tb_K + # Get the mean density of ocean layers and conducting/convecting upper ice layers + Planet.VLayer_m3 = 4/3*np.pi * (Planet.r_m[:-1]**3 - Planet.r_m[1:]**3) + Planet.Ocean.Vtot_m3 = np.sum(Planet.VLayer_m3[Planet.phase == 0]) + # Set validity flag + if Planet.invalidReason is None: + Planet.invalidReason = 'Valid' + + log.info(f'Non-self-consistent inner layers: ' + + f'Sil radius: {Planet.Sil.Rmean_m/1e3:.1f} km, ' + + f'Core radius: {Planet.Core.Rmean_m/1e3:.1f} km.') + + else: + # Set remaining quantities that are still None if the profile is invalid: + Planet.Ocean.Tmean_K, Planet.Sil.HtidalMean_Wm3, Planet.Ocean.rhoMean_kgm3, Planet.Ocean.Vtot_m3, \ + Planet.D_km, Planet.CMR2mean, Planet.CMR2less, Planet.CMR2more, Planet.MH2O_kg, Planet.MclathGas_kg, \ + Planet.Mclath_kg, Planet.Mcore_kg, Planet.Mfluid_kg, Planet.Mice_kg, Planet.MoceanSalt_kg, \ + Planet.Mocean_kg, Planet.MporeFluid_kg, Planet.MporeSalt_kg, Planet.Mrock_kg, Planet.Msalt_kg, \ + Planet.Mtot_kg, Planet.Sil.Rmean_m, Planet.Sil.Rrange_m, Planet.Core.Rmean_m, Planet.Core.Rrange_m, \ + Planet.Sil.rhoMean_kgm3, Planet.Core.rhoMean_kgm3, Planet.Sil.GSmean_GPa, Planet.Core.GSmean_GPa, \ + Planet.Pseafloor_MPa, Planet.Sil.phiCalc_frac, Planet.phiSeafloor_frac = (np.nan for _ in range(32)) + Planet.Sil.Rtrade_m, Planet.Core.Rtrade_m, Planet.Sil.rhoTrade_kgm3 \ + = (np.array([np.nan]) for _ in range(3)) + Planet.Steps.nHydro = 1 + Planet.Steps.nSil = 0 + Planet.Steps.nTotal = 1 + + return Planet, Params def CalcMoIConstantRho(Planet, Params): """ Find the relative sizes of silicate, core, and hydrosphere layers that are consistent with the measured moment of inertia, based on calculated hydrosphere @@ -1151,15 +1576,18 @@ def CalcMoIConstantRho(Planet, Params): nTooBig = next((i[0] for i, val in np.ndenumerate(VCore_m3) if val>0)) except StopIteration: msg = f'Failed to find a core size consistent with rhoSil = {Planet.Sil.rhoSilWithCore_kgm3:.1f} kg/m3 ' + \ - f'and xFeS = {Planet.Core.xFeS:.3f} for PHydroMax_MPa = {Planet.Ocean.PHydroMax_MPa:.1f}. ' + \ - 'Core size will be set to zero.' + f'and xFeS = {Planet.Core.xFeS:.3f} for PHydroMax_MPa = {Planet.Ocean.PHydroMax_MPa:.1f}. ' #+ \ + #'Core size will be set to zero.' if Params.DO_EXPLOREOGRAM: log.debug(msg) else: - log.warning(msg) + raise ValueError(msg) nTooBig = 0 rCore_m = np.zeros_like(VCore_m3) + Planet.Steps.nCore = 0 + INVALIDCORE = True else: + INVALIDCORE = False # Calculate corresponding core radii based on above density rCore_m = (VCore_m3[nTooBig:]*3/4/np.pi)**(1/3) @@ -1174,6 +1602,7 @@ def CalcMoIConstantRho(Planet, Params): # Set core radius and density to zero so calculations can proceed rCore_m = np.zeros(nHydroActual-1 - Planet.Steps.iSilStart) rhoCore_kgm3 = 0 + INVALIDCORE = False # Calculate C for a mantle extending up to each hydrosphere layer in turn C_kgm2 = np.zeros(nHydroActual - 1) @@ -1186,8 +1615,7 @@ def CalcMoIConstantRho(Planet, Params): CMR2inds = [i[0] for i, valCMR2 in np.ndenumerate(CMR2) if valCMR2 >= Planet.Bulk.Cmeasured - Planet.Bulk.CuncertaintyLower and valCMR2 <= Planet.Bulk.Cmeasured + Planet.Bulk.CuncertaintyUpper] - - if len(CMR2inds) == 0: + if len(CMR2inds) == 0 or INVALIDCORE: if Planet.Do.NO_H2O: suggestion = '\nTry adjusting properties of silicates and core to get C/MR^2 values in range.' else: @@ -1197,7 +1625,7 @@ def CalcMoIConstantRho(Planet, Params): f'Min: {np.min(CMR2[CMR2>0]):.3f}, Max: {np.max(CMR2):.3f}.' if Params.ALLOW_BROKEN_MODELS: fullMsg = msg + suggestion + ' Params.ALLOW_BROKEN_MODELS is True, so calculations will proceed with many values set to nan.' - if Params.DO_EXPLOREOGRAM or Params.DO_INDUCTOGRAM: + if Params.DO_EXPLOREOGRAM or Params.DO_INDUCTOGRAM or Params.DO_MONTECARLO: log.info(msg) else: log.error(fullMsg) @@ -1221,6 +1649,7 @@ def CalcMoIConstantRho(Planet, Params): Planet.Core.Rmean_m = np.nan Planet.Core.Rtrade_m = nans Planet.Core.Rrange_m = np.nan + Planet.Sil.rhoNoCore_kgm3 = np.nan Planet.Steps.nSil = Planet.Steps.nSilMax # Use Rset_m to indicate that we have already determined the core size in using SilicateLayers Planet.Core.Rset_m = np.nan @@ -1259,6 +1688,7 @@ def CalcMoIConstantRho(Planet, Params): Planet.Core.Rmean_m = rCore_m[iCMR2inner] Planet.Core.Rtrade_m = rCore_m[CMR2indsInner] Planet.Core.Rrange_m = np.max(Planet.Core.Rtrade_m) - np.min(Planet.Core.Rtrade_m) + Planet.Sil.rhoNoCore_kgm3 = rhoSil_kgm3[iCMR2inner] # Now we finally know how many layers there are in the hydrosphere Planet.Steps.nHydro = iCMR2 # Use Rset_m to indicate that we have already determined the core size in using SilicateLayers @@ -1273,7 +1703,7 @@ def CalcMoIConstantRho(Planet, Params): = SilicateLayers(Planet, Params) nSilTooBig = nProfiles - np.size(indsSilValid) - if Planet.Do.Fe_CORE: + if Planet.Do.Fe_CORE and Planet.Steps.nCore > 0: # Evaluate the core EOS for each layer nSilFinal, Pcore_MPa, Tcore_K, rCoreEOS_m, rhoCore_kgm3, MLayerCore_kg, gCore_ms2, CpCore_JkgK, alphaCore_pK, \ kThermCore_WmK = IronCoreLayers(Planet, Params, diff --git a/PlanetProfile/Thermodynamics/MgSO4/MgSO4Props.py b/PlanetProfile/Thermodynamics/MgSO4/MgSO4Props.py index 29e257f1..ece20850 100644 --- a/PlanetProfile/Thermodynamics/MgSO4/MgSO4Props.py +++ b/PlanetProfile/Thermodynamics/MgSO4/MgSO4Props.py @@ -4,10 +4,12 @@ from hdf5storage import loadmat from collections.abc import Iterable from scipy.interpolate import RegularGridInterpolator, RectBivariateSpline, interp1d -from seafreeze.seafreeze import seafreeze as SeaFreeze +import seafreeze.seafreeze as sfz from PlanetProfile import _ROOT +from itertools import repeat from PlanetProfile.Utilities.defineStructs import Constants, EOSlist -from PlanetProfile.Utilities.DataManip import ResetNearestExtrap, ReturnConstantPTw +from PlanetProfile.Utilities.DataManip import ResetNearestExtrap, ReturnConstantPTw, Nearest2DInterpolator as PhaseInterpolator +from PlanetProfile.Thermodynamics.Seafreeze.SeafreezeProps import GenerateSeafreezeChemicalPotentials # Assign logger log = logging.getLogger('PlanetProfile') @@ -210,11 +212,12 @@ def Integral(func, a, b, nPts=50): else: return result - -class MgSO4PhaseMargules: +class MgSO4PhaseMargulesOnDemand: """ Calculate phase of liquid/ice within the hydrosphere for an ocean with dissolved MgSO4, given a span of P_MPa, T_K, and w_ppt, based on models from Vance et al. 2014: https://doi.org/10.1016/j.pss.2014.03.011 + This class is used to calculate phase on demand, with P,T input queried directly. This is very slow, but is the most accurate. + See MgSO4PhaseMargules for a more efficient way that precomputes entire phase grid. """ def __init__(self, wOcean_ppt): self.w_ppt = wOcean_ppt @@ -234,22 +237,130 @@ def __call__(self, P_MPa, T_K): raise RuntimeError('For computational reasons, the Margules formulation has not been ' + 'fully implemented for arrays of P and T. Query MgSO4 phase for ' + 'single points only until a more rapid+accurate method is implemented.') - # - # elif((self.nPs != self.nTs) and not (self.nPs == 1 or self.nTs == 1)): - # # If arrays are different lengths, they are probably meant to get a 2D output - # P_MPa, T_K = np.meshgrid(P_MPa, T_K, indexing='ij') - - # Determine the chemical potential mu for the ocean liquid based on - # the Margules equations as in Eqs. 2-4 of Vance et al. 2014: - # http://dx.doi.org/10.1016/j.pss.2014.03.011 - DeltamuLiquid_Jkg = (CG.W_Jkg(P_MPa,T_K) * (1 - self.xH2O)**2 + Constants.R*T_K/(self.mBar_gmol*1e-3) * np.log(self.xH2O)) - DeltamuIce_Jkg = np.array([CG.DeltaH0_Jkg[phase] - T_K*CG.DeltaS0_JkgK[phase] + CG.CpRelativeIntegral[phase-1](T_K) - + CG.VRelativeIntegral[phase](P_MPa,T_K) for phase in range(1,7)]) - DeltamuAll_Jkg = np.insert(DeltamuIce_Jkg, 0, DeltamuLiquid_Jkg, axis=0) - # Set ice IV to have infinite chemical potential so it is never considered energetically favorable - DeltamuAll_Jkg[4] = np.inf - - return np.argmin(DeltamuAll_Jkg, axis=0) + + evalPts_sfz = np.array([P_MPa, T_K], dtype=object) + ptsh = (P_MPa.size, T_K.size) + max_phase_num = max([p for p in Constants.seafreeze_ice_phases.keys()]) + comp = np.full(ptsh + (max_phase_num + 1,), np.nan) + sfz_Pmin = np.min(P_MPa) + sfz_Pmax = np.max(P_MPa) + sfz_Tmin = np.min(T_K) + sfz_Tmax = np.max(T_K) + if P_MPa.size == 1: + sfz_deltaP = 0 + else: + sfz_deltaP = np.round(np.mean(np.diff(P_MPa)), 2) + sfz_deltaT = np.round(np.mean(np.diff(T_K)), 2) + seafreezeRange = f'Pmin_{sfz_Pmin}_Pmax_{sfz_Pmax}_Tmin_{sfz_Tmin}_Tmax_{sfz_Tmax}_deltaP_{sfz_deltaP}_deltaT_{sfz_deltaT}' + seafreezeMuTag = f'mu_J_mol_{seafreezeRange}' + + for phase, name in Constants.seafreeze_ice_phases.items(): + seafreezeMuPhaseTag = f"{seafreezeMuTag}_{name}" + if seafreezeMuPhaseTag in EOSlist.loaded.keys(): + mu_J_mol = EOSlist.loaded[seafreezeMuPhaseTag] + # Ensure we handle single value arrays properly + if P_MPa.size == 1 or T_K.size == 1: + # Create a proper grid for seafreeze to work with + sfz_PT = np.array([P_MPa, T_K], dtype=object) + mu_J_mol = (sfz.getProp(sfz_PT, name).G * Constants.m_gmol['H2O'] / 1000) + else: + try: + mu_J_mol = (sfz.getProp(evalPts_sfz, name).G * Constants.m_gmol['H2O'] / 1000) + except: + from seafreeze.seafreeze import defpath, _get_tdvs, _is_scatter + from seafreeze.seafreeze import phases as seafreeze_phases + from mlbspline import load + phasedesc = seafreeze_phases[name] + sp = load.loadSpline(defpath, phasedesc.sp_name) + # Sometimes seafreeze will fail to calculate some bulk properties for a given phase, so we will use its imports + # directly to calculate only the chemical potential + isscatter = _is_scatter(evalPts_sfz) + tdvs = _get_tdvs(sp, evalPts_sfz, isscatter) + mu_J_mol = tdvs.G * Constants.m_gmol['H2O'] / 1000 + sl = tuple(repeat(slice(None), 2)) + (phase,) + if phase == 0: + # First assign to new variable to avoid modifying original array mu_J_mol which is saved to EOSlist + MgSO4MargulesAdjustedMu_Jmol = np.array([CG.W_Jkg(P,T_K) for P in P_MPa]) + adjusted_mu_Jmol = mu_J_mol + (MgSO4MargulesAdjustedMu_Jmol * (1 - self.xH2O)**2 + Constants.R*T_K/(self.mBar_gmol*1e-3) * np.log(self.xH2O)) * Constants.m_gmol['H2O'] / 1000 + mu_J_mol = adjusted_mu_Jmol + comp[sl] = np.squeeze(mu_J_mol) + all_nan_sl = np.all(np.isnan(comp), -1) # Find slices where all values are nan along the innermost axis + phases = np.zeros((P_MPa.size, T_K.size), dtype=np.uint8) + phases[~all_nan_sl] = np.nanargmin(comp[~all_nan_sl], -1) + return phases + + def arrays(self, P_MPa, T_K, grid=True): + self.nPs = np.size(P_MPa) + self.nTs = np.size(T_K) + if self.nPs * self.nTs > 300: + WARN_LONG = True + else: + WARN_LONG = False + if(self.nPs == 0 or self.nTs == 0): + # If input is empty, return empty array + return np.array([]) + elif self.nPs == 1 and self.nTs == 1: + phase = self.__call__(P_MPa, T_K) + elif self.nPs == 1: + if WARN_LONG: + log.debug(f'Applying Margules phase finder for MgSO4 with {self.nTs} T values. This may take some time.') + phase = np.array([self.__call__(P_MPa, T) for T in T_K]) + elif self.nTs == 1: + if WARN_LONG: + log.debug(f'Applying Margules phase finder for MgSO4 with {self.nPs} P values. This may take some time.') + phase = np.array([self.__call__(P, T_K) for P in P_MPa]) + elif self.nTs == self.nPs: + if WARN_LONG: + log.debug(f'Applying Margules phase finder for MgSO4 with {self.nPs} (P,T) pairs. This may take some time.') + phase = np.array([self.__call__(P, T_K[i]) for i, P in np.ndenumerate(P_MPa)]) + else: + if WARN_LONG: + log.debug(f'Applying Margules phase finder for MgSO4 with {self.nPs} P values and ' + + f'{self.nTs} T values. This may take some time.') + phase = np.array([[self.__call__(P, T) for T in T_K] for P in P_MPa]) + + if not grid and np.size(phase) == 1 and isinstance(phase, Iterable): + phase = phase[0] + return phase + +class MgSO4PhaseMargules: + """ Calculate phase of liquid/ice within the hydrosphere for an ocean with + dissolved MgSO4, given a span of P_MPa, T_K, and w_ppt, based on models + from Vance et al. 2014: https://doi.org/10.1016/j.pss.2014.03.011 + + This function has been updated to use seafreeze for the chemical potential of all ice phases and the reference pure water. The Margules equations are still used to calculate the chemical potential of the MgSO4 addition. This approach is similar to that used in the CustomSolution implementation (see ReaktoroProps.py) + """ + def __init__(self, P_MPa, T_K, wOcean_ppt): + self.w_ppt = wOcean_ppt + self.xH2O, self.mBar_gmol = Massppt2molFrac(self.w_ppt, Constants.m_gmol['MgSO4']) + self.Tmin = 0 + self.Tmax = np.inf + self.Pmin = 0 + self.Pmax = np.inf + phaseLookupGrid = self.phaseLookupGridGenerator(P_MPa, T_K) + self.fn_phase = PhaseInterpolator(P_MPa, T_K, phaseLookupGrid) + + def __call__(self, P_MPa, T_K): + return self.fn_phase + + def phaseLookupGridGenerator(self, P_MPa, T_K): + """ First, we will get the minimum chemical potentail of ice phases along PT grid input and its associated most stable phase.""" + seafreezeMuTag, seafreezeIcePhaseTag, seafreezePureWaterMuTag = GenerateSeafreezeChemicalPotentials(P_MPa, T_K, doPureWater=True) + """ Next, we will get the chemical potential of the ocean liquid phase. Namely, we will get the pure water chemical potential from seafreeze and adjust with MgSO4 based on Margules parameterization.""" + # Update the pure water chemical potential with MgSO4 based on Margules parameterization + MgSO4MargulesAdjustedMu_Jmol = np.array([CG.W_Jkg(P,T_K) for P in P_MPa]) + # Compute liquid chemical potential + liquid_chemical_potential = EOSlist.loaded[seafreezePureWaterMuTag] + (MgSO4MargulesAdjustedMu_Jmol * (1 - self.xH2O)**2 + Constants.R*T_K/(self.mBar_gmol*1e-3) * np.log(self.xH2O)) * Constants.m_gmol['H2O'] / 1000 + + """ Finally, we will get the most stable phase between the aqueous phase and the ice phase.""" + # Boolean mask where ice is more stable than liquid + liquidLessStable = np.isnan(liquid_chemical_potential) | (liquid_chemical_potential > EOSlist.loaded[seafreezeMuTag]) + + # Directly assign ice phases using boolean mask + phases = np.zeros((P_MPa.size, T_K.size), dtype=np.uint8) + phases[liquidLessStable] = EOSlist.loaded[seafreezeIcePhaseTag][liquidLessStable] + + return phases def arrays(self, P_MPa, T_K, grid=True): self.nPs = np.size(P_MPa) @@ -478,7 +589,7 @@ def LarionovKryukov1984(w_ppt, rhoType='Millero', scalingType='Vance2018'): [1.0051, 1.0015, 0.9885, 0.9668, 0.9374, 0.9374, 0.9374]]) * 1e3 elif rhoType == 'SeaFreeze': # Values in Larionov and Kryukov are calculated assuming pure water densities - rhoLK_kgm3 = SeaFreeze(np.array([PLK_MPa, TLK_K], dtype=object), 'water1').rho + rhoLK_kgm3 = sfz.getProp(np.array([PLK_MPa, TLK_K], dtype=object), 'water1').rho else: raise ValueError(f'Unrecognized rhoType "{rhoType}".') # Reconfiguring the first Eq. from Larionov and Kryukov (1984), and using diff --git a/PlanetProfile/Thermodynamics/OceanProps.py b/PlanetProfile/Thermodynamics/OceanProps.py index 28970a90..22e8b241 100644 --- a/PlanetProfile/Thermodynamics/OceanProps.py +++ b/PlanetProfile/Thermodynamics/OceanProps.py @@ -3,7 +3,8 @@ import scipy.interpolate as spi from PlanetProfile.Thermodynamics.HydroEOS import GetOceanEOS from PlanetProfile.Utilities.Indexing import GetPhaseIndices -from PlanetProfile.Utilities.defineStructs import Constants, EOSlist +from PlanetProfile.Utilities.defineStructs import Constants, EOSlist, Timing +import time # Assign logger log = logging.getLogger('PlanetProfile') def LiquidOceanPropsCalcs(Planet, Params): @@ -15,55 +16,123 @@ def LiquidOceanPropsCalcs(Planet, Params): Ocean.aqueousSpecies Ocean. """ + Timing.setFunctionTime(time.time()) # Only perform calculations if this is a valid profile - if Planet.Do.VALID: - # Identify indices of liquid phases - indsLiq, indsI, indsIwet, indsII, indsIIund, indsIII, indsIIIund, indsV, indsVund, indsVI, indsVIund, \ - indsClath, indsClathWet, indsMixedClathrateIh, indsMixedClathrateII, indsMixedClathrateIII, indsMixedClathrateV, indsMixedClathrateVI, \ - indsMixedClathrateIhwet, indsMixedClathrateIIund, indsMixedClathrateIIIund, indsMixedClathrateVund, indsMixedClathrateVIund, \ - indsSil, indsSilLiq, indsSilI, indsSilII, indsSilIII, indsSilV, indsSilVI, \ - indsFe = GetPhaseIndices(Planet.phase) + setNaN = False + if (Planet.Do.VALID or (Params.ALLOW_BROKEN_MODELS and Planet.Do.STILL_CALCULATE_BROKEN_PROPERTIES)) and not Planet.Do.NON_SELF_CONSISTENT: + if Params.CALC_OCEAN_PROPS: + + # Identify indices of liquid phases + indsLiq, indsI, indsIwet, indsII, indsIIund, indsIII, indsIIIund, indsV, indsVund, indsVI, indsVIund, \ + indsClath, indsClathWet, indsMixedClathrateIh, indsMixedClathrateII, indsMixedClathrateIII, indsMixedClathrateV, indsMixedClathrateVI, \ + indsMixedClathrateIhwet, indsMixedClathrateIIund, indsMixedClathrateIIIund, indsMixedClathrateVund, indsMixedClathrateVIund, \ + indsSil, indsSilLiq, indsSilI, indsSilII, indsSilIII, indsSilV, indsSilVI, \ + indsFe = GetPhaseIndices(Planet.phase) - if not Planet.Do.NO_OCEAN and Planet.Ocean.EOS.key not in EOSlist.loaded.keys(): - POcean_MPa = np.arange(Planet.PfreezeLower_MPa, Planet.Ocean.PHydroMax_MPa, Planet.Ocean.deltaP) - TOcean_K = np.arange(Planet.Bulk.Tb_K, Planet.Ocean.THydroMax_K, Planet.Ocean.deltaT) - Planet.Ocean.EOS = GetOceanEOS(Planet.Ocean.comp, Planet.Ocean.wOcean_ppt, POcean_MPa, TOcean_K, - Planet.Ocean.MgSO4elecType, rhoType=Planet.Ocean.MgSO4rhoType, - scalingType=Planet.Ocean.MgSO4scalingType, FORCE_NEW=Params.FORCE_EOS_RECALC, - phaseType=Planet.Ocean.phaseType, EXTRAP=Params.EXTRAP_OCEAN, - sigmaFixed_Sm=Planet.Ocean.sigmaFixed_Sm) - # Check if we have liquid phases - if np.size(indsLiq) != 0: - # If so, then get pH and speciation of ocean - Planet.Ocean.Bulk_pHs, Planet.Ocean.aqueousSpeciesAmount_mol, Planet.Ocean.aqueousSpecies = ( - Planet.Ocean.EOS.fn_species(Planet.P_MPa[indsLiq], Planet.T_K[indsLiq])) - Planet.Ocean.Mean_pH = np.mean(Planet.Ocean.Bulk_pHs) - if "CustomSolution" in Planet.Ocean.comp and Planet.Ocean.reaction is not None: - Planet.Ocean.affinity_kJ = Planet.Ocean.EOS.fn_rxn_affinity(Planet.P_MPa[indsLiq], Planet.T_K[indsLiq], Planet.Ocean.reaction, Planet.Ocean.reactionDisequilibriumConcentrations) - Planet.Ocean.affinityMean_kJ = np.mean(Planet.Ocean.affinity_kJ) + if not Planet.Do.NO_OCEAN and Planet.Ocean.EOS.key not in EOSlist.loaded.keys(): + POcean_MPa = np.arange(Planet.PfreezeLower_MPa, Planet.Ocean.PHydroMax_MPa, Planet.Ocean.deltaP) + TOcean_K = np.arange(Planet.Bulk.Tb_K, Planet.Ocean.THydroMax_K, Planet.Ocean.deltaT) + Planet.Ocean.EOS = GetOceanEOS(Planet.Ocean.comp, Planet.Ocean.wOcean_ppt, POcean_MPa, TOcean_K, + Planet.Ocean.MgSO4elecType, rhoType=Planet.Ocean.MgSO4rhoType, + scalingType=Planet.Ocean.MgSO4scalingType, FORCE_NEW=Params.FORCE_EOS_RECALC, + phaseType=Planet.Ocean.phaseType, EXTRAP=Params.EXTRAP_OCEAN, + sigmaFixed_Sm=Planet.Ocean.sigmaFixed_Sm, propsStepReductionFactor=Planet.Ocean.propsStepReductionFactor) + # Check if we have liquid phases + if np.size(indsLiq) != 0: + if 'CustomSolution' in Planet.Ocean.comp: + # If so, then get pH and speciation of ocean + Planet.Ocean.Reaction = setupReactionSubstruct(Planet.Ocean.Reaction) + Planet.Ocean.Bulk_pHs, Planet.Ocean.aqueousSpeciesAmount_mol, Planet.Ocean.aqueousSpecies, Planet.Ocean.affinity_kJ = ( + Planet.Ocean.EOS.fn_species(Planet.P_MPa[indsLiq], Planet.T_K[indsLiq], reactionSubstruct = Planet.Ocean.Reaction)) + else: + Planet.Ocean.Bulk_pHs, Planet.Ocean.aqueousSpeciesAmount_mol, Planet.Ocean.aqueousSpecies = ( + Planet.Ocean.EOS.fn_species(Planet.P_MPa[indsLiq], Planet.T_K[indsLiq])) + Planet.Ocean.affinity_kJ = np.repeat(np.nan, len(indsLiq)) + Planet.Ocean.Mean_pH = np.mean(Planet.Ocean.Bulk_pHs) + Planet.Ocean.pHSeafloor = Planet.Ocean.Bulk_pHs[-1] + Planet.Ocean.pHTop = Planet.Ocean.Bulk_pHs[0] Planet.Ocean.affinitySeafloor_kJ = Planet.Ocean.affinity_kJ[-1] + Planet.Ocean.affinityTop_kJ = Planet.Ocean.affinity_kJ[0] + Planet.Ocean.affinityMean_kJ = np.mean(Planet.Ocean.affinity_kJ) else: - Planet.Ocean.affinity_kJ = (np.zeros(np.size(indsLiq))) * np.nan - Planet.Ocean.affinitySeafloor_kJ = np.nan - Planet.Ocean.affinityMean_kJ = np.nan - Planet.Ocean.reaction = 'NaN' - Planet.Ocean.reactionDisequilibriumConcentrations = 'NaN' + setNaN = True else: - Planet.Ocean.Bulk_pHs, Planet.Ocean.Mean_pH, Planet.Ocean.aqueousSpeciesAmount_mol, Planet.Ocean.aqueousSpecies, Planet.Ocean.affinity_kJ, Planet.Ocean.affinitySeafloor_kJ, Planet.Ocean.affinityMean_kJ = np.nan, np.nan, np.nan, np.nan, np.nan, np.nan, np.nan - Planet.Ocean.reaction = 'NaN' - Planet.Ocean.reactionDisequilibriumConcentrations = 'NaN' + setNaN = True else: - Planet.Ocean.Bulk_pHs, Planet.Ocean.Mean_pH, Planet.Ocean.aqueousSpeciesAmount_mol, Planet.Ocean.aqueousSpecies, Planet.Ocean.affinity_kJ, Planet.Ocean.affinitySeafloor_kJ, Planet.Ocean.affinityMean_kJ = np.nan, np.nan, np.nan, np.nan, np.nan, np.nan, np.nan - Planet.Ocean.reaction = 'NaN' - Planet.Ocean.reactionDisequilibriumConcentrations = 'NaN' + setNaN = True + if setNaN: + Planet.Ocean.Bulk_pHs, Planet.Ocean.Mean_pH, Planet.Ocean.pHSeafloor, Planet.Ocean.pHTop, \ + Planet.Ocean.aqueousSpeciesAmount_mol, Planet.Ocean.aqueousSpecies, Planet.Ocean.affinity_kJ, \ + Planet.Ocean.affinitySeafloor_kJ, Planet.Ocean.affinityTop_kJ, Planet.Ocean.affinityMean_kJ = np.repeat(np.nan, 10) + Timing.printFunctionTimeDifference('LiquidOceanPropsCalcs()', time.time()) return Planet +def setupReactionSubstruct(reactionSubstruct): + if reactionSubstruct.reaction is None: + reactionSubstruct.reaction = 'NaN' + if reactionSubstruct.reaction != 'NaN': + reactionSubstruct.parsed_reaction = reaction_parser(reactionSubstruct.reaction) + for species in reactionSubstruct.parsed_reaction["allSpecies"]: + if reactionSubstruct.useReferenceSpecies: + reactionSubstruct.disequilibriumConcentrations[species] = None + referenceSpecies = reactionSubstruct.referenceSpecies + if reactionSubstruct.useH2ORatio: + referenceRatioToH2O = reactionSubstruct.mixingRatioToH2O[referenceSpecies] + if species in reactionSubstruct.mixingRatioToH2O.keys(): + reactionSubstruct.disequilibriumConcentrations[species] = reactionSubstruct.mixingRatioToH2O[species] / referenceRatioToH2O + else: + if species in reactionSubstruct.relativeRatioToReferenceSpecies.keys(): + reactionSubstruct.disequilibriumConcentrations[species] = reactionSubstruct.disequilibriumConcentrations[referenceSpecies] + else: + reactionSubstruct.disequilibriumConcentrations = {} + reactionSubstruct.useReferenceSpecies = False + reactionSubstruct.useH2ORatio = False + reactionSubstruct.referenceSpecies = 'NaN' + return reactionSubstruct + +def reaction_parser(reaction): + """ + Parse a chemical reaction string into reactants, products, and optional disequilibrium species. + + Parameters: + reaction_str (str): The chemical reaction string (e.g., "CO2 + 4 H2(aq) -> CH4(aq) + 2 H2O(aq)"). + Returns: + dict: Parsed reaction with reactants, products, and optional disequilibrium species. + """ + reaction_parts = reaction.split("->") + reactants_str, products_str = reaction_parts[0], reaction_parts[1] + + def parse_side(side_str): + species_dict = {} + components = side_str.split("+") + for component in components: + component = component.strip() + if " " in component: + coeff, species = component.split(" ", 1) + species_dict[species.strip()] = float(coeff) + else: + species_dict[component.strip()] = 1.0 + return species_dict + + reactants = parse_side(reactants_str) + products = parse_side(products_str) + + + return {"reactants": reactants, "products": products, "allSpecies": reactants.keys() | products.keys()} + def WriteLiquidOceanProps(Planet, Params): """ Write out liquid ocean property calculations to disk """ + if not Planet.Ocean.Reaction.useReferenceSpecies: + reactionSpeciesDescription = 'Reaction Species Relative Ratio at Disequilibrium' + else: + reactionSpeciesDescription = f'Reaction Species Relative Ratio to {Planet.Ocean.Reaction.referenceSpecies} at Disequilibrium' + headerLines = [ - f'Significant Species in Ocean = ' + f'{", ".join(Planet.Ocean.aqueousSpecies)}', - f'Reaction Considered in Ocean = {Planet.Ocean.reaction}', - f'Concentration of Reaction Species at Disequilibrium = {Planet.Ocean.reactionDisequilibriumConcentrations}' + f'Significant Species in Ocean = ' + f'{"; ".join(Planet.Ocean.aqueousSpecies)}', + f'Reaction Considered in Ocean = {Planet.Ocean.Reaction.reaction}', + f'Use Reference Species = {Planet.Ocean.Reaction.useReferenceSpecies}', + f'Reference Species = {Planet.Ocean.Reaction.referenceSpecies}', + f'{reactionSpeciesDescription} = {Planet.Ocean.Reaction.disequilibriumConcentrations}', ] colHeaders = ([' P (MPa)'.ljust(24), @@ -89,7 +158,7 @@ def WriteLiquidOceanProps(Planet, Params): f'{Planet.Ocean.Bulk_pHs[i]:24.17e}', f'{Planet.Ocean.affinity_kJ[i]:24.17e}']) for j in range(len(Planet.Ocean.aqueousSpecies)): - line = line + f'{Planet.Ocean.aqueousSpeciesAmount_mol[j][i]:24.17e}' + line = line + f'{Planet.Ocean.aqueousSpeciesAmount_mol[j][i]:24.17e} ' line = line + '\n' f.write(line) log.info(f'Ocean specific properties saved to file: {Params.DataFiles.oceanPropsFile}') diff --git a/PlanetProfile/Thermodynamics/Reaktoro/CustomSolution.py b/PlanetProfile/Thermodynamics/Reaktoro/CustomSolution.py index 8e350712..6a201ac9 100644 --- a/PlanetProfile/Thermodynamics/Reaktoro/CustomSolution.py +++ b/PlanetProfile/Thermodynamics/Reaktoro/CustomSolution.py @@ -22,8 +22,9 @@ def SetupCustomSolution(Planet, Params): # Flag that we are not using wOcean_ppt as independent parameter - used in file name generation Planet.Do.USE_WOCEAN_PPT = False Planet.Ocean.wOcean_ppt = wpptCalculator(Planet.Ocean.comp.split('=')[1].strip()) - Params = SetupCustomSolutionPlotSettings(np.array(Planet.Ocean.comp), Params) - SetupCustomSolutionEOS(Planet.Ocean.comp, Planet.Ocean.wOcean_ppt) + if not Params.PRELOAD_EOS_IN_PROGRESS: + Params = SetupCustomSolutionPlotSettings(np.array(Planet.Ocean.comp), Params) + SetupCustomSolutionEOS(Planet.Ocean.comp, Planet.Ocean.wOcean_ppt) return Planet, Params @@ -36,6 +37,7 @@ def SetupCustomSolutionEOS(CustomSolutionComp, wOcean_ppt): CustomSolutionComp, wOcean_ppt) # Call function to generate EOS table EOSLookupTableLoader(aqueous_species_string, speciation_ratio_mol_kg, ocean_solid_phases, EOS_lookup_label) + return def SetupCustomSolutionPlotSettings(PlanetOceanArray, Params): @@ -49,6 +51,9 @@ def SetupCustomSolutionPlotSettings(PlanetOceanArray, Params): CustomSolutionOceanComp = str(CustomSolutionOceanComp) # Here we need to add the Planets CustomSolution composition to some parameter dictionaries for plotting purposes, which we must do dynamically since input can be anything # Add wRef_ppts - namely, we will add the Planet.Ocean.wOcean_ppt and any wRef_ppt in CustomSolution + if CustomSolutionOceanComp not in Params.wRef_ppt: + Params.wRef_ppt[CustomSolutionOceanComp] = Params.wRef_ppt["CustomSolution"] + Params.fNameRef[CustomSolutionOceanComp] = f'{CustomSolutionOceanComp}Ref.txt' if CustomSolutionOceanComp not in Color.cmapName: if FigMisc.CustomSolutionSingleCmap: Color.cmapName[CustomSolutionOceanComp] = Color.CustomSolutionCmapNames[0] @@ -68,10 +73,9 @@ def SetupCustomSolutionPlotSettings(PlanetOceanArray, Params): Color.cmapBounds[CustomSolutionOceanComp] = Color.cmapBounds["CustomSolution"] Color.saturation[CustomSolutionOceanComp] = Color.saturation["CustomSolution"] Color.SetCmaps() + if CustomSolutionOceanComp not in Style.LS: Style.LS[CustomSolutionOceanComp] = Style.LS["CustomSolution"] Style.LS_ref[CustomSolutionOceanComp] = Style.LS_ref["CustomSolution"] - Params.wRef_ppt[CustomSolutionOceanComp] = Params.wRef_ppt["CustomSolution"] - Params.fNameRef[CustomSolutionOceanComp] = f'{CustomSolutionOceanComp}Ref.txt' return Params def strip_latex_formatting_from_CustomSolutionLabel(s): diff --git a/PlanetProfile/Thermodynamics/Reaktoro/WIP/Europa_Exploreogram/Europa/figures/.gitignore b/PlanetProfile/Thermodynamics/Reaktoro/WIP/Europa_Exploreogram/Europa/figures/.gitignore new file mode 100644 index 00000000..49ad046e --- /dev/null +++ b/PlanetProfile/Thermodynamics/Reaktoro/WIP/Europa_Exploreogram/Europa/figures/.gitignore @@ -0,0 +1,2 @@ +*.png +*.pdf \ No newline at end of file diff --git a/PlanetProfile/Thermodynamics/Reaktoro/reaktoroProps.py b/PlanetProfile/Thermodynamics/Reaktoro/reaktoroProps.py index a645003b..323dd30d 100644 --- a/PlanetProfile/Thermodynamics/Reaktoro/reaktoroProps.py +++ b/PlanetProfile/Thermodynamics/Reaktoro/reaktoroProps.py @@ -5,36 +5,19 @@ from PlanetProfile.Utilities.defineStructs import Constants, EOSlist from PlanetProfile.GetConfig import CustomSolutionParams from hdf5storage import loadmat, savemat -from PlanetProfile.Utilities.DataManip import ResetNearestExtrap, ReturnConstantPTw +from PlanetProfile.Utilities.DataManip import ResetNearestExtrap, ReturnConstantPTw, Nearest2DInterpolator as PhaseInterpolator from collections.abc import Iterable -from scipy.interpolate import RegularGridInterpolator +from scipy.interpolate import RegularGridInterpolator, RectBivariateSpline from scipy.optimize import root_scalar as GetZero from itertools import repeat import hashlib +from PlanetProfile.Thermodynamics.Seafreeze.SeafreezeProps import GenerateSeafreezeChemicalPotentials # Assign logger log = logging.getLogger('PlanetProfile') # Get FileLock, which is important to prevent race conditions in parallel computing when saving EOS from multiprocessing import Lock FileLock = Lock() - -""" -Check CustomSolutionConfig inputs are valid, set file paths, and save global reference to object so Rkt file can use -""" -# Ensure frezchem database is valid -CustomSolutionParams.setPaths(_ROOT) -for file_name in os.listdir(CustomSolutionParams.databasePath): - if file_name == CustomSolutionParams.FREZCHEM_DATABASE: - break -else: - log.warning( - "Input frezchem database does not match any of the available saved files.\nCheck that the input is properly spelled and has .dat at end. Using default frezchem.dat file") - CustomSolutionParams.FREZCHEM_DATABASE = "frezchem.dat" -# Check the unit is 'g' or 'mol' (g - grams, mol - mols) -if not CustomSolutionParams.SPECIES_CONCENTRATION_UNIT == "g" and not CustomSolutionParams.SPECIES_CONCENTRATION_UNIT == "mol": - log.warning( - "Input species concentration unit is not valid. Check that it is either g or mol. Using mol as default") - CustomSolutionParams.SPECIES_CONCENTRATION_UNIT = "mol" def MolalConverter(ocean_species_string_g_kg): @@ -150,20 +133,23 @@ def SpeciesParser(species_string_with_ratios_mol_kg, w_ppt): EOS_lookup_label = "_".join(f"{key}-{value}" for key, value in final_speciation_mol_kg.items()) # If we are considering solid phases, then get all relevant solid phases if CustomSolutionParams.SOLID_PHASES: - if CustomSolutionParams.SOLID_PHASES_TO_CONSIDER == 'All': - solid_phases = 'All' + if CustomSolutionParams.SOLID_PHASES_TO_CONSIDER == "All": + solid_phases_to_consider = "All" else: - solid_phases = [] + solid_phases_to_consider = [] for solid_phase in CustomSolutionParams.SOLID_PHASES_TO_CONSIDER: if solid_phase in Constants.SolidPhases: - solid_phases = solid_phases + Constants.SolidPhases[solid_phase] + solid_phases_to_consider = (solid_phases_to_consider + Constants.SolidPhases[solid_phase]) else: - solid_phases.append(solid_phase) + solid_phases_to_consider.append(solid_phase) # Get only the solid phases that are relevant to the system - reduces runtime by not considering solids that would not appear in system db = rkt.SupcrtDatabase(CustomSolutionParams.SUPCRT_DATABASE) - supcrt_aqueous_species_string, supcrt_speciation_ratio_mol_kg = species_convertor_compatible_with_supcrt(db, - aqueous_species_string, final_speciation_mol_kg, Constants.PhreeqcToSupcrtNames) - solid_phases_to_consider = RelevantSolidSpecies(db, supcrt_aqueous_species_string, solid_phases) + supcrt_aqueous_species_string, supcrt_speciation_ratio_mol_kg = species_convertor_compatible_with_supcrt(db,aqueous_species_string,final_speciation_mol_kg,Constants.PhreeqcToSupcrtNames) + if CustomSolutionParams.SOLID_PHASES_TO_SUPPRESS is None: + solid_phases_to_suppress = [] + else: + solid_phases_to_suppress = CustomSolutionParams.SOLID_PHASES_TO_SUPPRESS + solid_phases_to_consider = RelevantSolidSpecies(db,supcrt_aqueous_species_string,solid_phases_to_consider,solid_phases_to_suppress) # Append solids to EOS lookup label EOS_lookup_label = f'{EOS_lookup_label}_{"_".join(solid_phases_to_consider.split())}' else: @@ -377,26 +363,26 @@ def __init__(self, aqueous_species_string, speciation_ratio_mol_per_kg, ocean_so save_dict_to_pkl(self.phase_EOS, self.phase_fLookup) - def RktFreezingTemperatureFinder(self, Frezchem_System, P_MPa, TMin_K=220, TMax_K=300, significant_threshold=0.1): + def RktFreezingTemperatureFinder(self, Frezchem_System, P_MPa, TMin_K=220, TMax_K=300, significant_threshold=0.01): + """ + Calculates the temperature at which the prescribed aqueous solution freezes. Utilizes the reaktoro framework to + constrain the equilibrium position at the prescribed pressure and the chemical potential difference between ice and liquid water at 0.1, + therefore calculating and returning the temperature (within the range) at which ice begins to form. + + Parameters + ---------- + speciation_ratio_mol_per_kg: the ratio of species in the aqueous solution in mol/kg of water. Should be a dictionary + with the species as the key and its ratio as its value. + P_MPa: the desired equilibrium freezing pressure(s). + TMin_K: the lower limit of temperature that Reaktoro should query over + TMax_K: the upper limit of temperature that Reaktoro should query over + significant_threshold: the amount of moles of ice present for H2O to be considered in solid phase. Default is 1e-14 moles. + + Returns + ------- + t_freezing_K: the temperature at which the solution begins to freeze. + P_MPa_adjusted: adjusted pressure range that removes values that did not converge """ - Calculates the temperature at which the prescribed aqueous solution freezes. Utilizes the reaktoro framework to - constrain the equilibrium position at the prescribed pressure and the chemical potential difference between ice and liquid water at 0.1, - therefore calculating and returning the temperature (within the range) at which ice begins to form. - - Parameters - ---------- - speciation_ratio_mol_per_kg: the ratio of species in the aqueous solution in mol/kg of water. Should be a dictionary - with the species as the key and its ratio as its value. - P_MPa: the desired equilibrium freezing pressure(s). - TMin_K: the lower limit of temperature that Reaktoro should query over - TMax_K: the upper limit of temperature that Reaktoro should query over - significant_threshold: the amount of moles of ice present for H2O to be considered in solid phase. Default is 1e-14 moles. - - Returns - ------- - t_freezing_K: the temperature at which the solution begins to freeze. - P_MPa_adjusted: adjusted pressure range that removes values that did not converge - """ # Disable chemical convergence warnings that Reaktoro raises. We handle these internally instead and throw more specific warnings when they appear. rkt.Warnings.disable(906) # Create freezing temperatures list and indices of pressures to remove, if necessary @@ -624,14 +610,13 @@ def RktProps(EOSLookupTable, P_MPa, T_K, EXTRAP): P_MPa = np.linspace(P_MPa[0], P_MPa[-1] + EOS_deltaP, 5) EOS_deltaP = np.maximum(np.round(np.mean(np.diff(P_MPa)), 2), 0.001) - evalPts = fn_RktProps.fn_evalPts(P_MPa, T_K) nPs = np.size(P_MPa) # Interpolate the input data to get the values corresponding to the current ocean comp, # then get the property values for the input (P,T) pairs and reshape to how they need # to be formatted for use in the ocean EOS. - rho_kgm3 = np.reshape(fn_RktProps.fn_rho_kgm3(evalPts), (nPs, -1)) - Cp_JkgK = np.reshape(fn_RktProps.fn_Cp_JkgK(evalPts), (nPs, -1)) - alpha_pK = np.reshape(fn_RktProps.fn_alpha_pK(evalPts), (nPs, -1)) + rho_kgm3 = np.reshape(fn_RktProps.fn_rho_kgm3(P_MPa, T_K), (nPs, -1)) + Cp_JkgK = np.reshape(fn_RktProps.fn_Cp_JkgK(P_MPa, T_K), (nPs, -1)) + alpha_pK = np.reshape(fn_RktProps.fn_alpha_pK(P_MPa, T_K), (nPs, -1)) kTherm_WmK = fn_RktProps.fn_kTherm_WmK(P_MPa, T_K, 0, grid =True) return P_MPa, T_K, rho_kgm3, Cp_JkgK, alpha_pK, kTherm_WmK, EOS_deltaP, EOS_deltaT @@ -639,43 +624,23 @@ def RktProps(EOSLookupTable, P_MPa, T_K, EXTRAP): class RktPropsLookup: def __init__(self, EOSLookupTable): - self.fLookup = f'{EOSLookupTable.name}_Props' - if self.fLookup in EOSlist.loaded.keys(): - log.debug(f'EOS properties lookup table with label {self.fLookup} already loaded.') - self.fn_rho_kgm3, self.fn_Cp_JkgK, self.fn_alpha_pK, self.fn_kTherm_WmK, self.fn_VP_kms, self.fn_KS_GPa, self.fn_mu_J_mol, self.fn_evalPts = \ - EOSlist.loaded[self.fLookup] - self.Pmin, self.Pmax, self.EOSdeltaP, self.Tmin, self.Tmax, self.EOSdeltaT = EOSlist.ranges[self.fLookup] - else: - fRktProps = EOSLookupTable.props_EOS - TRkt_K = fRktProps['T_K'] - PRkt_MPa = fRktProps['P_MPa'] - self.fn_rho_kgm3 = RegularGridInterpolator((PRkt_MPa, TRkt_K), fRktProps['rho'], method='linear', - bounds_error=False, fill_value=None) - self.fn_Cp_JkgK = RegularGridInterpolator((PRkt_MPa, TRkt_K), fRktProps['Cp'], method='linear', - bounds_error=False, fill_value=None) - self.fn_alpha_pK = RegularGridInterpolator((PRkt_MPa, TRkt_K), fRktProps['alpha'], method='linear', - bounds_error=False, fill_value=None) - self.fn_VP_kms = RegularGridInterpolator((PRkt_MPa, TRkt_K), fRktProps['VP'], method='linear', - bounds_error=False, fill_value=None) - self.fn_KS_GPa = RegularGridInterpolator((PRkt_MPa, TRkt_K), fRktProps['KS'], method='linear', - bounds_error=False, fill_value=None) - self.fn_mu_J_mol = RegularGridInterpolator((PRkt_MPa, TRkt_K), fRktProps['mu'], method='linear', - bounds_error=False, fill_value=None) - self.fn_kTherm_WmK = ReturnConstantPTw(const=Constants.kThermWater_WmK) - - self.Pmin = EOSLookupTable.Pmin - self.Pmax = EOSLookupTable.Pmax - self.EOSdeltaP = EOSLookupTable.EOSdeltaP - self.Tmin = EOSLookupTable.Tmin - self.Tmax = EOSLookupTable.Tmax - self.EOSdeltaT = EOSLookupTable.EOSdeltaT - - self.fn_kTherm_WmK = ReturnConstantPTw(const=Constants.kThermWater_WmK) - # Save functions to EOSlist so they can be referenced in future - EOSlist.loaded[self.fLookup] = ( - self.fn_rho_kgm3, self.fn_Cp_JkgK, self.fn_alpha_pK, self.fn_kTherm_WmK, self.fn_VP_kms, self.fn_KS_GPa, - self.fn_mu_J_mol, self.fn_evalPts) - EOSlist.ranges[self.fLookup] = (self.Pmin, self.Pmax, self.EOSdeltaP, self.Tmin, self.Tmax, self.EOSdeltaT) + fRktProps = EOSLookupTable.props_EOS + TRkt_K = fRktProps["T_K"] + PRkt_MPa = fRktProps["P_MPa"] + self.fn_rho_kgm3 = RectBivariateSpline(PRkt_MPa, TRkt_K, fRktProps["rho"]) + self.fn_Cp_JkgK = RectBivariateSpline(PRkt_MPa, TRkt_K, fRktProps["Cp"]) + self.fn_alpha_pK = RectBivariateSpline(PRkt_MPa, TRkt_K, fRktProps["alpha"]) + self.fn_VP_kms = RectBivariateSpline(PRkt_MPa, TRkt_K, fRktProps["VP"]) + self.fn_KS_GPa = RectBivariateSpline(PRkt_MPa, TRkt_K, fRktProps["KS"]) + self.fn_mu_J_mol = RectBivariateSpline(PRkt_MPa, TRkt_K, fRktProps["mu"]) + self.fn_kTherm_WmK = ReturnConstantPTw(const=Constants.kThermWater_WmK) + + self.Pmin = EOSLookupTable.Pmin + self.Pmax = EOSLookupTable.Pmax + self.EOSdeltaP = EOSLookupTable.EOSdeltaP + self.Tmin = EOSLookupTable.Tmin + self.Tmax = EOSLookupTable.Tmax + self.EOSdeltaT = EOSLookupTable.EOSdeltaT def fn_evalPts(self, Pin_MPa, Tin_K): P_MPa = ensureArray(Pin_MPa) @@ -932,12 +897,8 @@ def __call__(self, P_MPa, T_K, grid=False): self.WARNED = True P_MPa = newP_MPa T_K = newT_K - if grid: - evalPts = tuple(np.meshgrid(P_MPa, T_K, indexing='ij')) - else: - evalPts = np.column_stack((P_MPa, T_K)) - VP_kms = np.squeeze(self.fn_VP_kms(evalPts)) - KS_GPa = np.squeeze(self.fn_KS_GPa(evalPts)) + VP_kms = self.fn_VP_kms(P_MPa, T_K, grid=grid) + KS_GPa = self.fn_KS_GPa(P_MPa, T_K, grid=grid) return VP_kms, KS_GPa def fn_evalPts(self, Pin_MPa, Tin_K): @@ -947,98 +908,64 @@ def fn_evalPts(self, Pin_MPa, Tin_K): return np.array(out) class RktPhaseLookup: - def __init__(self, EOSLookupTable, P_MPa, T_K, EOS_deltaP, EOS_deltaT): - self.fLookup = f'{EOSLookupTable.name}_Phase' - if self.fLookup in EOSlist.loaded.keys(): - log.debug(f'EOS phase lookup table with label {self.fLookup} already loaded.') - self.fn_frezchem_phaseRGI, self.fn_mu_J_mol = EOSlist.loaded[self.fLookup] - self.Pmin, self.Pmax, self.deltaP, self.deltaT = EOSlist.ranges[self.fLookup] - else: - fRktPhase = EOSLookupTable.phase_EOS - PRkt_MPa = fRktPhase['P_MPa'] - self.Pmin = np.min(PRkt_MPa) - self.deltaP = np.maximum(np.round(np.mean(np.diff(PRkt_MPa)), 2), 0.001) - self.fn_frezchem_phaseRGI = RegularGridInterpolator((PRkt_MPa,), fRktPhase['TFreezing_K'], - method='linear', bounds_error=False, fill_value=None) - - # Gets the RktPropsLookup again. This should be quick as we have already loaded it into EOSlist using RktProps called before - fn_RktProps = RktPropsLookup(EOSLookupTable) + def __init__(self, EOSLookupTable, P_MPa, T_K): + inputdeltaP = np.maximum(np.round(np.mean(np.diff(P_MPa)), 3), 0.001) + inputdeltaT = np.maximum(np.round(np.mean(np.diff(T_K)), 3), 0.001) + fRktPhase = EOSLookupTable.phase_EOS + PRkt_MPa = fRktPhase['P_MPa'] + self.Pmin = np.min(PRkt_MPa) + EOSdeltaP = np.maximum(np.round(np.mean(np.diff(PRkt_MPa)), 3), 0.001) + fn_frezchem_phaseRGI = RegularGridInterpolator((PRkt_MPa,), fRktPhase['TFreezing_K'], + method='linear', bounds_error=False, fill_value=None) - # Get the temperature limits - self.Pmax = fn_RktProps.Pmax - self.Tmin = fn_RktProps.Tmin - self.Tmax = fn_RktProps.Tmax - self.deltaT = fn_RktProps.EOSdeltaT - # Reassign the functions so they can be referenced when object is called - self.fn_mu_J_mol = fn_RktProps.fn_mu_J_mol + # Gets the RktPropsLookup again. This should be quick as we have already loaded it into EOSlist using RktProps called before + fn_RktProps = RktPropsLookup(EOSLookupTable) - EOSlist.loaded[self.fLookup] = self.fn_frezchem_phaseRGI, self.fn_mu_J_mol - EOSlist.ranges[self.fLookup] = (self.Pmin, self.Pmax, self.deltaP, self.deltaT) + # Get the temperature limits + self.Pmax = fn_RktProps.Pmax + self.Tmin = fn_RktProps.Tmin + self.Tmax = fn_RktProps.Tmax + EOSdeltaT = fn_RktProps.EOSdeltaT + # Reassign the functions so they can be referenced when object is called + fn_mu_J_mol = fn_RktProps.fn_mu_J_mol + # Generate pressure and temperature arrays that extend to limits of EOS pressure and temperature but # use the fidelity of the temperature and pressure steps of EOS (i.e. deltaT and deltaP) - self.deltaP = np.min([self.deltaP, EOS_deltaP]) - self.deltaT = np.min([self.deltaT, EOS_deltaT]) - self.P_MPa_to_query = np.arange(P_MPa[0], P_MPa[-1], self.deltaP) - self.T_K_to_query = np.arange(T_K[0], T_K[-1], self.deltaT) - self.phase_lookup_grid = self.phase_lookup_grid_generator(self.P_MPa_to_query, self.T_K_to_query, self.fn_frezchem_phaseRGI, self.fn_mu_J_mol) - self.fn_phase = RegularGridInterpolator((self.P_MPa_to_query, self.T_K_to_query), self.phase_lookup_grid, method='nearest', bounds_error=False, fill_value=None) + self.deltaP = np.min([inputdeltaP, EOSdeltaP]) + self.deltaT = np.min([inputdeltaT, EOSdeltaT]) + P_MPa_to_query = np.arange(P_MPa[0], P_MPa[-1], self.deltaP) + T_K_to_query = np.arange(T_K[0], T_K[-1], self.deltaT) + phase_lookup_grid = self.phase_lookup_grid_generator(P_MPa_to_query, T_K_to_query, fn_frezchem_phaseRGI, fn_mu_J_mol) + self.fn_phase = PhaseInterpolator(P_MPa_to_query, T_K_to_query, phase_lookup_grid) def __call__(self, P_MPa, T_K, grid=False): - if grid: - P_MPa, T_K = np.meshgrid(P_MPa, T_K, indexing='ij') - return (self.fn_phase((P_MPa, T_K))) + return self.fn_phase(P_MPa, T_K, grid=grid) def phase_lookup_grid_generator(self, P_MPa, T_K, freezing_temperature_function_below_200_MPa, mu_function_above_200_MPa): - P_MPa_below_200_MPa_index = np.searchsorted(P_MPa, 200, side = 'left') - phases = [] + P_MPa_below_200_MPa_index = np.searchsorted(P_MPa, 200, side="left") + phases = np.zeros((P_MPa.size, T_K.size), dtype=np.uint8) if P_MPa_below_200_MPa_index > 0: P_MPa_below_200_MPa = P_MPa[0:P_MPa_below_200_MPa_index] freezing_temperatures = freezing_temperature_function_below_200_MPa(P_MPa_below_200_MPa) - freezing_temperatures, T_K_pts = np.meshgrid(freezing_temperatures, T_K, indexing='ij') - phases = phases + (T_K_pts < freezing_temperatures).astype(np.int_).tolist() - + freezing_temperatures, T_K_pts = np.meshgrid(freezing_temperatures, T_K, indexing="ij") + phases[0:P_MPa_below_200_MPa_index, :] = (T_K_pts < freezing_temperatures).astype(np.int_) if P_MPa_below_200_MPa_index < P_MPa.size: P_MPa_above_200_MPa = P_MPa[P_MPa_below_200_MPa_index:] - evalPts_RGI = tuple(np.meshgrid(P_MPa_above_200_MPa, T_K)) - # Make sure we're always passing a properly formatted array to seafreeze - # Create a 2-element array with pressure and temperature arrays - evalPts_sfz = np.array([P_MPa_above_200_MPa, T_K], dtype=object) - ptsh = (P_MPa_above_200_MPa.size, T_K.size) - max_phase_num = max([p for p in Constants.seafreeze_ice_phases.keys()]) - comp = np.full(ptsh + (max_phase_num + 1,), np.nan) + if len(P_MPa_above_200_MPa) == len(T_K): + P_MPa_To_Query = np.concatenate((P_MPa_above_200_MPa, [P_MPa_above_200_MPa[-1] + 1])) + else: + P_MPa_To_Query = P_MPa_above_200_MPa + """ First, we will get the minimum chemical potentail of ice phases along PT grid input and its associated most stable phase.""" + sfzIceMuTag, sfzIcePhaseTag, _ = GenerateSeafreezeChemicalPotentials(P_MPa_To_Query, T_K) + """ Next, we will get the chemical potential of the ocean liquid phase. Namely, we will get the pure water chemical potential from seafreeze and adjust with chemical potential adjustment.""" + # Compute liquid chemical potential + aqMu_J_mol = mu_function_above_200_MPa(P_MPa_To_Query, T_K) - for phase, name in Constants.seafreeze_ice_phases.items(): - if phase == 0: - mu_J_mol = mu_function_above_200_MPa(evalPts_RGI).T - else: - # Ensure we handle single value arrays properly - if P_MPa_above_200_MPa.size == 1 or T_K.size == 1: - # Create a proper grid for seafreeze to work with - sfz_P, sfz_T = np.meshgrid(P_MPa_above_200_MPa, T_K, indexing='ij') - sfz_PT = np.array([sfz_P.flatten(), sfz_T.flatten()], dtype=object) - mu_J_mol = sfz.getProp(sfz_PT, name).G * Constants.m_gmol['H2O'] / 1000 - mu_J_mol = mu_J_mol.reshape(sfz_P.shape) - else: - try: - mu_J_mol = sfz.getProp(evalPts_sfz, name).G * Constants.m_gmol['H2O'] / 1000 - except: - from seafreeze.seafreeze import defpath, _get_tdvs, _is_scatter - from seafreeze.seafreeze import phases as seafreeze_phases - from mlbspline import load - phasedesc = seafreeze_phases[name] - sp = load.loadSpline(defpath, phasedesc.sp_name) - # Calc density and isentropic bulk modulus - isscatter = _is_scatter(evalPts_sfz) - tdvs = _get_tdvs(sp, evalPts_sfz, isscatter) - mu_J_mol = tdvs.G * Constants.m_gmol['H2O'] / 1000 - #raise ValueError(f"Error in seafreeze calculation for phase {name}. Check the input values {mu_J_mol}.") - sl = tuple(repeat(slice(None), 2)) + (phase,) - comp[sl] = np.squeeze(mu_J_mol) - all_nan_sl = np.all(np.isnan(comp), -1) # Find slices where all values are nan along the innermost axis - out_phase = np.full(ptsh, np.nan) - out_phase[~all_nan_sl] = np.nanargmin(comp[~all_nan_sl], -1) - phases = phases + out_phase.tolist() - phases = np.array(phases).reshape(P_MPa.size, T_K.size) + """ Finally, we will get the most stable phase between the aqueous phase and the ice phase.""" + # Boolean mask where ice is more stable than liquid + liqLessStable = np.isnan(aqMu_J_mol) | (aqMu_J_mol > EOSlist.loaded[sfzIceMuTag]) + + phases[P_MPa_below_200_MPa_index:, :][liqLessStable] = EOSlist.loaded[sfzIcePhaseTag][liqLessStable] return phases @@ -1073,39 +1000,63 @@ def __call__(self, P_MPa, T_K, grid=False): grid (bool): Whether or not to convert to coordinate grid (optional) Returns: - VP_kms (float, Shape N): Corresponding sound speeds in km/s - KS_GPa (float, Shape N): Corresponding bulk modulus in GPa + sigma_Sm (float or array): Electrical conductivity in S/m """ # Ensure P_MPa and T_K are numpy arrays P_MPa, T_K = np.array(P_MPa), np.array(T_K) - # Store the original P_MPa and T_K for the cache key - original_P_MPa, original_T_K = P_MPa, T_K - - # If grid is needed or if inputs are not 1D arrays, create a grid - if grid or (P_MPa.ndim != 1 or T_K.ndim != 1): - P_MPa, T_K = np.meshgrid(P_MPa, T_K, indexing='ij') - elif P_MPa.size == 0 or T_K.size == 0: - # Return empty array if input is empty + # Handle empty inputs + if P_MPa.size == 0 or T_K.size == 0: return np.array([]) - # Check if arrays are mismatched but might be intended for grid - if P_MPa.size != T_K.size and not (P_MPa.size == 1 or T_K.size == 1): + # Store original arrays for cache key + original_P_MPa, original_T_K = P_MPa.copy(), T_K.copy() + + # Determine if we need to create a grid + grid = grid or (P_MPa.ndim != 1 or T_K.ndim != 1) or (P_MPa.size != T_K.size and not (P_MPa.size == 1 or T_K.size == 1)) + + # If we need grid then make P_MPa and T_K into a grid + if grid: P_MPa, T_K = np.meshgrid(P_MPa, T_K, indexing='ij') - grid = True # Indicate that grid should be used - - # Check if speciation data has already been calculated for this grid - key = (tuple(P_MPa.ravel()), tuple(T_K.ravel())) # Use original arrays for the key - if key not in self.calculated_speciations: - # Calculate speciation if not found - self.calculated_speciations[key] = self.fn_species(original_P_MPa, original_T_K, grid=grid) - pH, speciation, species_names = self.calculated_speciations[key] - - # Convert species names to compatible format - McClevsky_speciation = self.McClevskyIonParser(speciation, species_names, self.speciation_ratio_mol_per_kg) - # McCleskey function requires Celcius units + # Get speciation data + key = (tuple(original_P_MPa.ravel()), tuple(original_T_K.ravel())) + CALC_SPECIATION = False + + if CALC_SPECIATION: + if key not in self.calculated_speciations: + self.calculated_speciations[key] = self.fn_species(original_P_MPa, original_T_K, grid=grid) + pH, speciation, species_names, affinity = self.calculated_speciations[key] + else: + # Use constant speciation ratios + speciation = [] + species_names = [] + + for species, ratio in self.speciation_ratio_mol_per_kg.items(): + ratio_array = np.full(P_MPa.shape, ratio) + speciation.append(ratio_array) + species_names.append(species) + + speciation = np.array(speciation) + species_names = np.array(species_names) + + # Calculate conductivity T_C = T_K - Constants.T0 - return elecCondMcCleskey2012(P_MPa, T_C, McClevsky_speciation) + + if grid: + # Handle grid case: calculate for each temperature slice + sigma_Sm = np.zeros_like(P_MPa) + for i in range(T_K.shape[0]): + speciation_slice = speciation[:, i, :] + mccleskey_ions = self.McClevskyIonParser(speciation_slice, species_names, + self.speciation_ratio_mol_per_kg) + sigma_Sm[i, :] = elecCondMcCleskey2012(P_MPa[i, :], T_C[i, :], mccleskey_ions) + else: + # Handle non-grid case + mccleskey_ions = self.McClevskyIonParser(speciation, species_names, + self.speciation_ratio_mol_per_kg) + sigma_Sm = elecCondMcCleskey2012(P_MPa, T_C, mccleskey_ions) + + return sigma_Sm def McClevskyIonParser(self, speciation_array, species_names_array, speciation_ratio_mol_kg): """ @@ -1160,9 +1111,10 @@ def __init__(self, aqueous_species_list, speciation_ratio_mol_kg, ocean_solid_ph # Create a dictionary of calculated speciations that will hold calculated speciations for input P_MPa and T_K self.calculated_speciations = {} - def __call__(self, P_MPa, T_K, grid=False): - """ Calculates speciation of composition at provided pressure and temperature using Supcrt. Notably, - we have to reset the pressure since we cannot calculate equilibrium above 500MPa using Supcrt.""" + def __call__(self, P_MPa, T_K, grid=False, reactionSubstruct=None): + """Calculates speciation of composition at provided pressure and temperature using Supcrt. Notably, + we have to reset the pressure since we cannot calculate equilibrium above 500MPa using Supcrt. + """ # Reset P_MPa so that it does not extend above 500MPa, since supcrt cannot go above this pressure newP_MPa, newT_K = ResetNearestExtrap(P_MPa, T_K, P_MPa[0], Constants.SupcrtPmax_MPa, T_K[0], T_K[-1]) if (not np.all(newP_MPa == P_MPa)) or (not np.all(newT_K == T_K)): @@ -1188,77 +1140,243 @@ def __call__(self, P_MPa, T_K, grid=False): if grid: P_MPa_flat = P_MPa.ravel() T_K_flat = T_K.ravel() - pH, species, species_names = self.species_at_equilibrium(P_MPa_flat, T_K_flat) + pH, species, species_names, affinity = self.species_at_equilibrium(P_MPa_flat, T_K_flat, reactionSubstruct) # Reshape species to (num_species, P_MPa.size, T_K.size) num_species = species_names.size species = species.reshape((num_species, P_MPa.shape[0], P_MPa.shape[1])) pH = pH.reshape(P_MPa.shape) else: - pH, species, species_names = self.species_at_equilibrium(P_MPa, T_K) + pH, species, species_names, affinity = self.species_at_equilibrium(P_MPa, T_K, reactionSubstruct) # Let's save the speciation in the dictionary (which we will reference in RktConduct to reduce runtime) - self.calculated_speciations[(tuple(P_MPa.ravel()), tuple(T_K.ravel()))] = pH, species, species_names - return pH, species, species_names + self.calculated_speciations[(tuple(P_MPa.ravel()), tuple(T_K.ravel()))] = pH, species, species_names, affinity + return pH, species, species_names, affinity - def species_at_equilibrium(self, P_MPa, T_K): + def species_at_equilibrium(self, P_MPa, T_K, reactionSubstruct=None): """ Go through P_MPa and T_K and calculate equilibrium speciation of aqueous and solid species, as well as pH. Return species above """ + if reactionSubstruct is None or reactionSubstruct.reaction == "NaN": + calcReaction = False + else: + calcReaction = True + """ Setup the reaction structure """ + for species in reactionSubstruct.parsed_reaction["allSpecies"]: + if species not in self.speciation_ratio_mol_kg.keys(): + self.speciation_ratio_mol_kg[species] = 0 + if not reactionSubstruct.useReferenceSpecies: + if reactionSubstruct.disequilibriumConcentrations[species] is not None: + self.speciation_ratio_mol_kg[species] = reactionSubstruct.disequilibriumConcentrations[species] # Keep track of time it takes to do calculation start_time = time.time() # Establish supcrt generator db, system, initial_state, conditions, solver, props = SupcrtGenerator(self.aqueous_species_list, self.speciation_ratio_mol_kg, "mol", CustomSolutionParams.SUPCRT_DATABASE, self.ocean_solid_phases, Constants.PhreeqcToSupcrtNames, CustomSolutionParams.maxIterations) state = initial_state.clone() + reactionState = initial_state.clone() # State just for storign equilibrium state before calculating Q if we are using reference species # Prepare lists for pH and species amounts pH_list = [] - species_list = [[] for _ in range(len(system.species()))] + affinity_list = [] + species_amount_list = [[] for _ in range(len(system.species()))] + species_volume_list = [[] for _ in range(len(system.species()))] species_names = np.array([species.name() for species in system.species()]) # Extract species names for P, T in zip(P_MPa, T_K): - conditions.pressure(P, "MPa") + setNaN = False + # Set conditions conditions.temperature(T, "K") - # Solve the equilibrium problem using the hot-start approach - result = solver.solve(state, conditions) - if not result.succeeded(): - # Attempt a cold start - state = initial_state.clone() + conditions.pressure(P, "MPa") + state.setPressure(P, "MPa") + state.setTemperature(T, "K") + if calcReaction: + if reactionSubstruct.useReferenceSpecies: + # We use reactionState here to store equilibrium calculations with reference species separately, + # which speeds up computation time before we add in disequilibrium concentrations to calculate affinity + reactionState.setPressure(P, "MPa") + reactionState.setTemperature(T, "K") + # Equilibriate reaction state + result = solver.solve(reactionState, conditions) + if not result.succeeded(): + # Retry with cold start + reactionState = initial_state.clone() + result = solver.solve(reactionState, conditions) + if not result.succeeded(): + setNaN = True + reactionState = initial_state.clone() + else: + # Update props with reaction state + props.update(reactionState) + # Copy the reactionState to the main state which gives us equilibrium of ocean + state = reactionState.clone() + referenceSpeciesAmount = props.speciesAmount(reactionSubstruct.referenceSpecies) + # Update main state with disequilibrium concentrations based on reference species equilibrium concentration + for species in reactionSubstruct.parsed_reaction["allSpecies"]: + if reactionSubstruct.disequilibriumConcentrations[species] is not None and species != reactionSubstruct.referenceSpecies: + state.setSpeciesAmount(species, float(referenceSpeciesAmount * reactionSubstruct.disequilibriumConcentrations[species]), "mol") + props.update(state) # Finally update props with the state that has the disequilibrium concentrations + else: + # Otherwise if we have specified absolute disequilibrium concentrations, then these are the concentrations we will use to calculate Q + props.update(initial_state) + if not setNaN: + # Calculate Q disequilibrium constant + Q = self.calculate_reaction_quotient( + props, reactionSubstruct.parsed_reaction + ) + if not setNaN: + conditions.pressure(P, "MPa") + conditions.temperature(T, "K") + # Solve the equilibrium problem using the hot-start approach result = solver.solve(state, conditions) - if result.succeeded(): - # Update props and extract data - props.update(state) - aprops = rkt.AqueousProps(props) - pH_list.append(float(aprops.pH())) - for k, species in enumerate(system.species()): - species_list[k].append(float(state.speciesAmount(species.name()))) - else: + if not result.succeeded(): + # Attempt a cold start + state = initial_state.clone() + result = solver.solve(state, conditions) + if result.succeeded(): + # Update props and extract data + props.update(state) + if calcReaction: + # Calculate K equilibrium constant + K = self.calculate_reaction_quotient(props, reactionSubstruct.parsed_reaction) + # Calculate affinity + R = 8.31446 + A = 2.3026 * R * T * (np.log10(K) - np.log10(Q)) / 1000 # Affinity in kJ + # Store the affinity (A) + affinity_list.append(A) + else: + affinity_list.append(np.nan) + aprops = rkt.AqueousProps(props) + pH_list.append(float(aprops.pH())) + for k, species in enumerate(system.species()): + if species.aggregateState() != rkt.AggregateState.Aqueous: + species_amount_list[k].append(float(props.phaseProps(species.name()).volume())*100**3) + else: + species_amount_list[k].append(float(state.speciesAmount(species.name()))) + if species_amount_list[k][-1] < 0: + species_amount_list[k][-1] = 1e-40 + else: + setNaN = True + if setNaN: # If we fail to find equilibrium, let's just append the pH from last successful attempt log.warning(f"Failed to find equilibrium at {P} MPa and {T} K. Filling with NaN.") pH_list.append(np.nan) + affinity_list.append(np.nan) + setNaN = True for k in range(len(system.species())): - species_list[k].append(np.nan) + species_amount_list[k].append(np.nan) # Reset after each temperature state = initial_state.clone() # Convert lists to arrays pH_array = np.array(pH_list) - species_array = np.array(species_list) + species_array = np.array(species_amount_list) + affinity_array = np.array(affinity_list) + # In the case that any properties could not be calculated, we must linearly interpolate these + # THIS IS HIGHLY UNLIKELY SINCE WE HAVE FOUND CONSTRAINTS COMPATIBLE WITH RKT, BUT JUST IN CASE THIS IS IMPLEMENTED (has not been rigorously tested) + if np.sum(np.isnan(pH_array)) > 0: + log.warning(f'Interpolation failed for {np.sum(np.isnan(pH_array))} points.') + # Interpolate pH and affinit yarrays + pH_array = interpolation_1d(P_MPa, [pH_array])[0] + affinity_array = interpolation_1d(P_MPa, [affinity_array])[0] + # Interpolate species arrays + species_individual_arrays = [species_array[i] for i in range(len(species_array))] + interpolated_arrays = interpolation_1d(P_MPa, species_individual_arrays) + for i in range(len(species_individual_arrays)): + species_array[i] = interpolated_arrays[i] # Log time it took to calculate speciation end_time = time.time() log.debug(f'{end_time-start_time} seconds to calculate hydrosphere species') # Return the filtered results - return pH_array, species_array, species_names + return pH_array, species_array, species_names, affinity_array + + def calculate_reaction_quotient(self, prop, reaction): + + # Initialize numerator and denominator for Q + Q_numerator = 1.0 + Q_denominator = 1.0 + + # Multiply activities raised to their stoichiometric coefficients for products + for species, coefficient in reaction["products"].items(): + speciesActivity = float(prop.speciesActivity(species)) + log.debug(f"Species {species} activity: {speciesActivity}") + Q_numerator *= speciesActivity**coefficient + # Multiply activities raised to their stoichiometric coefficients for reactants + for species, coefficient in reaction["reactants"].items(): + speciesActivity = float(prop.speciesActivity(species)) + log.debug(f"Species {species} activity: {speciesActivity}") + Q_denominator *= speciesActivity**coefficient + + # Calculate the reaction quotient Q + Q = Q_numerator / Q_denominator + return Q + + +def temperature_constraint(T_K, System): + """ + Find the pressure constraint at which Reaktoro can find equilibrium for the given speciation and database. Checks if rkt can find equilibrium + with a pressure of 0.1 MPa at T_K temperature. If it cannot, then returns 0. If it can, then returns 1. + Args: + T_K: Initial temperature constraint in K + System: System tuple with following items: db, system, state, conditions, solver, props -class RktRxnAffinity(): + Returns: + 1 if equilibrium found + 0 if equilibrium not found + """ + # Initialize the database + db, system, state, conditions, solver, props = System + initial_state = state.clone() + # Establish pressure constraint of 1bar + conditions.pressure(1, "MPa") + conditions.temperature(T_K, "K") + # Solve the equilibrium problem + result = solver.solve(initial_state, conditions) + # Check if the equilibrium problem succeeded + if result.succeeded(): + return 1 + else: + return 0 + + +def pressure_constraint(P_MPa, System): + """Find the pressure constraint at which Reaktoro can find equilibrium for the given speciation and database. Checks if rkt can find equilibrium at 273K at P_MPa pressure. + If it cannot, then returns 0. If it can, then returns 1. + Args: + P_MPa: Pressure to find equilibrium: P_MPa + System: System tuple with following items: db, system, state, conditions, solver, props + + Returns: + 1 if equilibrium found + 0 if equilibrium not found + """ + # Disable chemical convergence warnings that Reaktoro raises. We handle these internally instead and throw more specific warnings when they appear. + rkt.Warnings.disable(906) + # Initilialize the database + db, system, state, conditions, solver, props = System + initial_state = state.clone() + # Establish pressure constraint of 1bar + conditions.pressure(P_MPa, "MPa") + conditions.temperature(Constants.T0, "K") + # Solve the equilibrium problem + result = solver.solve(initial_state, conditions) + # Check if the equilibrium problem succeeded + if result.succeeded(): + return 1 + else: + return 0 + + +# RktRxnAffinity is not used anymore, but is kept here for reference +"""class RktRxnAffinity: def __init__(self, aqueous_species_list, speciation_ratio_mol_per_kg, ocean_solid_species): # Convert H2O label to H2O(aq) label for compatability with Supcrt database db = rkt.SupcrtDatabase(CustomSolutionParams.SUPCRT_DATABASE) - self. aqueous_species_list, self.speciation_ratio_mol_per_kg = species_convertor_compatible_with_supcrt(db, aqueous_species_list, speciation_ratio_mol_per_kg, Constants.PhreeqcToSupcrtNames) + self.aqueous_species_list, self.speciation_ratio_mol_per_kg = species_convertor_compatible_with_supcrt(db, aqueous_species_list, speciation_ratio_mol_per_kg, Constants.PhreeqcToSupcrtNames) self.ocean_solid_species = ocean_solid_species - def __call__(self, P_MPa, T_K, reaction, concentrations, grid=False): - """ Calculates affinity of reaction, whose species are at prescribed concentrations at disequilibrium, at provided pressure and temperature - using Supcrt. Notably,we have to reset the pressure since we cannot calculate equilibrium above 500MPa using Supcrt.""" + def __call__(self, P_MPa, T_K, reactionSubstruct, grid=False): + Calculates affinity of reaction, whose species are at prescribed concentrations at disequilibrium, at provided pressure and temperature + using Supcrt. Notably,we have to reset the pressure since we cannot calculate equilibrium above 500MPa using Supcrt. + # Reset P_MPa so that it does not extend above 500MPa, since supcrt cannot go above this pressure newP_MPa, newT_K = ResetNearestExtrap(P_MPa, T_K, P_MPa[0], Constants.SupcrtPmax_MPa, T_K[0], T_K[-1]) if (not np.all(newP_MPa == P_MPa)) or (not np.all(newT_K == T_K)): @@ -1280,70 +1398,88 @@ def __call__(self, P_MPa, T_K, reaction, concentrations, grid=False): elif ((np.size(P_MPa) != np.size(T_K)) and not (np.size(P_MPa) == 1 or np.size(T_K) == 1)): # If arrays are different lengths, they are probably meant to get a 2D output P_MPa, T_K = np.meshgrid(P_MPa, T_K, indexing='ij') - parsed_reaction = self.reaction_parser(reaction) - + return self.reaction_affinity(reactionSubstruct, P_MPa, T_K) - return self.reaction_affinity(parsed_reaction, concentrations, P_MPa, T_K) + def reaction_affinity(self, reactionSubstruct, P_MPa, T_K): + Setup the reaction structure + for species in reactionSubstruct.parsed_reaction["allSpecies"]: + if species not in self.speciation_ratio_mol_per_kg.keys(): + self.speciation_ratio_mol_per_kg[species] = 0 + if not reactionSubstruct.useReferenceSpecies: + if reactionSubstruct.disequilibriumConcentrations[species] is not None: + self.speciation_ratio_mol_per_kg[species] = ( + reactionSubstruct.disequilibriumConcentrations[species] + ) - def reaction_affinity(self, reaction, rxn_disequilibrium_concentrations, P_MPa, T_K): - # First, parse through the rxn_disequilibrium_concentrations and convert to a dictionary of species and their concentrations - for species, concentration in rxn_disequilibrium_concentrations.items(): - if concentration is None: - concentration = self.speciation_ratio_mol_per_kg[species] - rxn_disequilibrium_concentrations[species] = concentration - if type(concentration) == float: - rxn_disequilibrium_concentrations[species] = concentration - elif type(concentration) == dict: - species_reference = concentration['reference species'] - lambda_function = concentration['equation'] - concentration = lambda_function(self.speciation_ratio_mol_per_kg[species_reference]) - rxn_disequilibrium_concentrations[species] = float(concentration) # Update speciation_ratio_mol_per_kg dictionary with disequilibrium concentrations - speciation_ratio_mol_per_kg = {**self.speciation_ratio_mol_per_kg, **rxn_disequilibrium_concentrations} - - aqueous_species_list = " ".join(speciation_ratio_mol_per_kg.keys()) + aqueous_species_list = " ".join(self.speciation_ratio_mol_per_kg.keys()) # Keep track of time it takes to do calculation start_time = time.time() + + # We need to increase the minimum mol treshold if we looking at small concentratiosn to prevent numerical errors since reaktoro,by default, increases all concentrations to 1e-16 even if they are below this number + if np.any([value < 1e-10 for value in self.speciation_ratio_mol_per_kg.values()]): + alwaysRecalculateState = True + else: + alwaysRecalculateState = False # Establish supcrt generator - db, system, initial_state, conditions, solver, props = SupcrtGenerator(aqueous_species_list, - speciation_ratio_mol_per_kg, - "mol", - CustomSolutionParams.SUPCRT_DATABASE, self.ocean_solid_species, Constants.PhreeqcToSupcrtNames, CustomSolutionParams.maxIterations) + db, system, initial_state, conditions, solver, props = SupcrtGenerator(aqueous_species_list, self.speciation_ratio_mol_per_kg, "mol", CustomSolutionParams.SUPCRT_DATABASE, self.ocean_solid_species, Constants.PhreeqcToSupcrtNames, CustomSolutionParams.maxIterations) affinity_kJ = [] # Create a copy of the state state = initial_state.clone() # Go through each P and T for P, T in zip(P_MPa, T_K): + setNaN = False # Set conditions conditions.temperature(T, "K") conditions.pressure(P, "MPa") state.setPressure(P, "MPa") state.setTemperature(T, "K") - # Let's always update props with the initial state that has disequilibrium speciation - props.update(initial_state) - # Calculate Q disequilibrium constant - Q = self.calculate_reaction_quotient(props, reaction) - # Solve the equilibrium problem - result = solver.solve(state, conditions) - if not result.succeeded(): - # Attempt a cold start + if reactionSubstruct.useReferenceSpecies: state = initial_state.clone() + # Equilibriate initial state result = solver.solve(state, conditions) - if result.succeeded(): - # Update the properties - props.update(state) - # Calculate K equilibrium constant - K = self.calculate_reaction_quotient(props, reaction) - - # Calculate affinity - R = 8.31446 - A = 2.3026 * R * T * (np.log10(K) - np.log10(Q)) / 1000 # Affinity in kJ - # Store the affinity (A) - affinity_kJ.append(A) + if not result.succeeded(): + setNaN = True + else: + props.update(state) + referenceSpeciesAmount = props.speciesAmount( + reactionSubstruct.referenceSpecies + ) + for species in reactionSubstruct.parsed_reaction["allSpecies"]: + if reactionSubstruct.disequilibriumConcentrations[species] is not None: + state.setSpeciesAmount(species, float(referenceSpeciesAmount * reactionSubstruct.disequilibriumConcentrations[species]), "mol") + props.update(state) else: - log.warning(f"Failed to find equilibrium at {P} MPa and {T} K. Filling with NaN.") + # Now that we have equilibrium state, then we should + # Let's always update props with the initial state that has disequilibrium speciation + props.update(initial_state) + if not setNaN: + # Calculate Q disequilibrium constant + Q = self.calculate_reaction_quotient(props, reactionSubstruct.parsed_reaction) + # Solve the equilibrium problem + result = solver.solve(state, conditions) + if not result.succeeded(): + # Attempt a cold start + state = initial_state.clone() + result = solver.solve(state, conditions) + if result.succeeded(): + # Update the properties + props.update(state) + # Calculate K equilibrium constant + K = self.calculate_reaction_quotient(props, reactionSubstruct.parsed_reaction) + # Calculate affinity + R = 8.31446 + A = 2.3026 * R * T * (np.log10(K) - np.log10(Q)) / 1000 # Affinity in kJ + # Store the affinity (A) + affinity_kJ.append(A) + if alwaysRecalculateState: + state = initial_state.clone() + else: + setNaN = True + if setNaN: + log.warning(f'Failed to find equilibrium at {P} MPa and {T} K. Filling with NaN.') affinity_kJ.append(np.nan) state = initial_state.clone() # Convert lists to arrays @@ -1365,99 +1501,19 @@ def calculate_reaction_quotient(self, prop, reaction): # Multiply activities raised to their stoichiometric coefficients for products for species, coefficient in reaction["products"].items(): speciesActivity = float(prop.speciesActivity(species)) + log.debug(f"Species {species} activity: {speciesActivity}") Q_numerator *= speciesActivity ** coefficient # Multiply activities raised to their stoichiometric coefficients for reactants for species, coefficient in reaction["reactants"].items(): speciesActivity = float(prop.speciesActivity(species)) + log.debug(f"Species {species} activity: {speciesActivity}") Q_denominator *= speciesActivity ** coefficient # Calculate the reaction quotient Q Q = Q_numerator / Q_denominator return Q +""" - def reaction_parser(self, reaction): - """ - Parse a chemical reaction string into reactants, products, and optional disequilibrium species. - - Parameters: - reaction_str (str): The chemical reaction string (e.g., "CO2 + 4 H2(aq) -> CH4(aq) + 2 H2O(aq)"). - - Returns: - dict: Parsed reaction with reactants, products, and optional disequilibrium species. - """ - reaction_parts = reaction.split("->") - reactants_str, products_str = reaction_parts[0], reaction_parts[1] - - def parse_side(side_str): - species_dict = {} - components = side_str.split("+") - for component in components: - component = component.strip() - if " " in component: - coeff, species = component.split(" ", 1) - species_dict[species.strip()] = float(coeff) - else: - species_dict[component.strip()] = 1.0 - return species_dict - - reactants = parse_side(reactants_str) - products = parse_side(products_str) - - - return {"reactants": reactants, "products": products} - -def temperature_constraint(T_K, System): - """ Find the pressure constraint at which Reaktoro can find equilibrium for the given speciation and database. Checks if rkt can find equilibrium - with a pressure of 0.1 MPa at T_K temperature. If it cannot, then returns 0. If it can, then returns 1. - Args: - T_K: Initial temperature constraint in K - System: System tuple with following items: db, system, state, conditions, solver, props - - Returns: - 1 if equilibrium found - 0 if equilibrium not found - """ - # Initialize the database - db, system, state, conditions, solver, props = System - initial_state = state.clone() - # Establish pressure constraint of 1bar - conditions.pressure(1, "MPa") - conditions.temperature(T_K, "K") - # Solve the equilibrium problem - result = solver.solve(initial_state, conditions) - # Check if the equilibrium problem succeeded - if result.succeeded(): - return 1 - else: - return 0 - - -def pressure_constraint(P_MPa, System): - """ Find the pressure constraint at which Reaktoro can find equilibrium for the given speciation and database. Checks if rkt can find equilibrium at 273K at P_MPa pressure. - If it cannot, then returns 0. If it can, then returns 1. - Args: - P_MPa: Pressure to find equilibrium: P_MPa - System: System tuple with following items: db, system, state, conditions, solver, props - - Returns: - 1 if equilibrium found - 0 if equilibrium not found - """ - # Disable chemical convergence warnings that Reaktoro raises. We handle these internally instead and throw more specific warnings when they appear. - rkt.Warnings.disable(906) - # Initilialize the database - db, system, state, conditions, solver, props = System - initial_state = state.clone() - # Establish pressure constraint of 1bar - conditions.pressure(P_MPa, "MPa") - conditions.temperature(Constants.T0, "K") - # Solve the equilibrium problem - result = solver.solve(initial_state, conditions) - # Check if the equilibrium problem succeeded - if result.succeeded(): - return 1 - else: - return 0 @@ -1526,8 +1582,8 @@ def seismic_calculations(self, aqueous_species_list, speciation_ratio_mol_kg, P_ densities = [] # Create Reaktoro objects db, system, initial_state, conditions, solver, props = SupcrtGenerator(aqueous_species_list, - speciation_ratio_mol_kg, - "mol", + speciation_ratio_mol_kg, + "mol", CustomSolutionParams.SUPCRT_DATABASE, CustomSolutionParams.SOLID_PHASES_TO_CONSIDER, Constants.PhreeqcToSupcrtNames, CustomSolutionParams.maxIterations) state = initial_state.clone() # Create an iterator to go through P_MPa and T_K @@ -1594,7 +1650,7 @@ def __init__(self, aqueous_species_list, speciation_ratio_mol_kg): self.speciation_ratio_mol_kg = speciation_ratio_mol_kg # Create both frezchem and core Reaktoro systems that can be utilized later on self.frezchem = PhreeqcGeneratorForChemicalConstraint(self.aqueous_species_list, self.speciation_ratio_mol_kg, - "mol", + "mol", CustomSolutionParams.frezchemPath, CustomSolutionParams.maxIterations) # Obtain internal temperature correction spline # self.temperature_correction_spline = FrezchemFreezingTemperatureCorrectionSplineGenerator() @@ -1619,14 +1675,14 @@ def __call__(self, P_MPa, T_K, grid=False): np.array(T_K) freezing_temperatures = self.rkt_t_freeze(self.aqueous_species_list, self.speciation_ratio_mol_kg, P_MPa, self.frezchem, self.PMax_MPa, self.spline_for_pressures_above_100_MPa, - self.calculated_freezing_temperatures, + self.calculated_freezing_temperatures, self.temperature_correction_spline) if grid: freezing_temperatures, T_K = np.meshgrid(freezing_temperatures, T_K, indexing='ij') return (T_K < freezing_temperatures).astype(np.int_) def Frezchem_Spline_Generator(self, aqueous_species_list, speciation_ratio_mol_kg, temperature_correction_spline, - data_points=30, + data_points=30, significant_threshold=0.1): P_MPa = np.linspace(0.1, 100, data_points) # Disable chemical convergence warnings that Reaktoro raises. We handle these internally instead and throw more specific warnings when they appear. @@ -1635,8 +1691,8 @@ def Frezchem_Spline_Generator(self, aqueous_species_list, speciation_ratio_mol_k TMax_K = 300 # Create Reaktoro objects db, system, initial_state, conditions, solver, props = PhreeqcGeneratorForChemicalConstraint(aqueous_species_list, - speciation_ratio_mol_kg, - "mol", + speciation_ratio_mol_kg, + "mol", CustomSolutionParams.frezchemPath, CustomSolutionParams.maxIterations) # Create freezing temperatures list and indices of pressures to remove, if necessary freezing_temperatures = [] @@ -1681,25 +1737,25 @@ def rkt_t_freeze(self, aqueous_species_list, speciation_ratio_mol_kg, P_MPa, fre TMin_K=220, TMax_K=300, significant_threshold=0.1): """ - Calculates the temperature at which the prescribed aqueous solution freezes. Utilizes the reaktoro framework to - constrain the equilibrium position at the prescribed pressure, the lower and upper limits of temperature (in K), - and the total amount of ice at a significant threshold of 1e-14, therefore calculating and returning the - temperature (within the range) at which ice begins to form. - - Parameters - ---------- - aqueous_species_list: aqueous species in reaction. Should be formatted in one long string with a space in between each species - speciation_ratio_mol_kg: the ratio of species in the aqueous solution in mol/kg of water. Should be a dictionary - with the species as the key and its ratio as its value. - P_MPa: the desired equilibrium freezing pressure(s). - TMin_K: the lower limit of temperature that Reaktoro should query over - TMax_K: the upper limit of temperature that Reaktoro should query over - significant_threshold: the amount of moles of ice present for H2O to be considered in solid phase. Default is 1e-14 moles. - - Returns - ------- - t_freezing_K: the temperature at which the solution begins to freeze. - """ + Calculates the temperature at which the prescribed aqueous solution freezes. Utilizes the reaktoro framework to + constrain the equilibrium position at the prescribed pressure, the lower and upper limits of temperature (in K), + and the total amount of ice at a significant threshold of 1e-14, therefore calculating and returning the + temperature (within the range) at which ice begins to form. + + Parameters + ---------- + aqueous_species_list: aqueous species in reaction. Should be formatted in one long string with a space in between each species + speciation_ratio_mol_kg: the ratio of species in the aqueous solution in mol/kg of water. Should be a dictionary + with the species as the key and its ratio as its value. + P_MPa: the desired equilibrium freezing pressure(s). + TMin_K: the lower limit of temperature that Reaktoro should query over + TMax_K: the upper limit of temperature that Reaktoro should query over + significant_threshold: the amount of moles of ice present for H2O to be considered in solid phase. Default is 1e-14 moles. + + Returns + ------- + t_freezing_K: the temperature at which the solution begins to freeze. + """ # Disable chemical convergence warnings that Reaktoro raises. We handle these internally instead and throw more specific warnings when they appear. # rkt.Warnings.disable(906) # Create list that holds boolean values of whether ice is present @@ -1736,7 +1792,7 @@ def rkt_t_freeze(self, aqueous_species_list, speciation_ratio_mol_kg, P_MPa, fre # If the result failed, we will use spline else: log.warning(f"While attempting to find bottom freezing temperature for pressure of {P} MPa, \n" - + f"Reaktoro was unable to find a temperature within range of {TMin_K} K and {TMax_K}.\n" + + f"Reaktoro was unable to find a temperature within range of {TMin_K} K and {TMax_K}.\n" + f"Instead, we will use a spline of freezing temperatures that we generated for this EOS to find the associated freezing temperature.") equilibrium_temperature = freezing_temperature_spline(P) state = initial_state.clone() diff --git a/PlanetProfile/Thermodynamics/Reaktoro/reaktoroPropsHelperFunctions.py b/PlanetProfile/Thermodynamics/Reaktoro/reaktoroPropsHelperFunctions.py index 4f97d670..2de77c07 100644 --- a/PlanetProfile/Thermodynamics/Reaktoro/reaktoroPropsHelperFunctions.py +++ b/PlanetProfile/Thermodynamics/Reaktoro/reaktoroPropsHelperFunctions.py @@ -157,9 +157,14 @@ def SupcrtGenerator(aqueous_species_list, speciation_ratio_per_kg, species_unit, # Set # of iterations to use options =rkt.EquilibriumOptions() options.optima.maxiters = iterations - solver.setOptions(options) + # Create a chemical state and its associated properties state = rkt.ChemicalState(system) + # We need to increase the minimum mol treshold if we looking at small concentratiosn to prevent numerical errors since reaktoro,by default, increases all concentrations to 1e-16 even if they are below this number + if np.any([value < 1e-16 for value in speciation_ratio_per_kg.values()]): + state.setSpeciesAmounts(1e-40) + options.epsilon = 1e-30 + solver.setOptions(options) # Populate the state with the prescribed species at the given ratios for ion, ratio in speciation_ratio_per_kg.items(): state.add(ion, ratio, species_unit) @@ -169,26 +174,29 @@ def SupcrtGenerator(aqueous_species_list, speciation_ratio_per_kg, species_unit, # Return the Reaktoro objects that user will need to interact with return db, system, state, conditions, solver, props -def RelevantSolidSpecies(db, aqueous_species_list, solid_phases): +def RelevantSolidSpecies(db, aqueous_species_list, solid_phases_to_consider, solid_phases_to_suppress): """ Finds the relevant solid species to consider from a list of solid phases, or if solid phases is None, then return all solids """ + solid_phases = '' # Prescribe the solution solution = rkt.AqueousPhase(aqueous_species_list) # If we are specifying what phases to consider, note that we should only consider the phases that are relevant to the species in the solution # I.e. don't consider all clathrates if they aren't possible to form (greatly decreases runtime if we consider only relevant phases) - solid_phases_to_consider = '' solids_tester = rkt.MineralPhases() system_tester = rkt.ChemicalSystem(db, solution, solids_tester) relevant_solid_phases = system_tester.species().withAggregateState(rkt.AggregateState.Solid) - if solid_phases == 'All': - solid_phases = [] + if solid_phases_to_consider == 'All': + solid_phases_to_consider = [] for solid_phase in relevant_solid_phases: - solid_phases.append(solid_phase.name()) - for solid in solid_phases: + if solid_phase.name() not in solid_phases_to_suppress: + solid_phases_to_consider.append(solid_phase.name()) + else: + log.debug(f"Solid phase {solid_phase.name()} is suppressed and will not be considered in the equilibrium calculations.") + for solid in solid_phases_to_consider: if relevant_solid_phases.findWithName(solid) < relevant_solid_phases.size(): - solid_phases_to_consider = solid_phases_to_consider + f' {solid}' - return solid_phases_to_consider + solid_phases += f' {solid}' + return solid_phases def ices_phases_amount_mol(props: rkt.ChemicalProps): @@ -324,15 +332,58 @@ def interpolation_2d(P_MPa, arrays): def interpolation_1d(P_MPa, arrays): interpolated_arrays = [] for array in arrays: + # Create a copy to avoid modifying the original array + array_copy = array.copy() + # Create mask for known values (not NaN) - nan_mask = np.isnan(array) - # Extract known points and values - x_known = P_MPa[~nan_mask] - y_known = array[~nan_mask] - spline = interpolate.make_interp_spline(x_known, y_known, k=2) - # Perform the interpolation - interpolated_results = spline(P_MPa) - interpolated_arrays.append(interpolated_results) + nan_mask = np.isnan(array_copy) + + # Step 1: Handle edge case filling - extend valid edge values to cover all invalid edge regions + # This prevents spline extrapolation which can cause numerical instability + valid_indices = np.where(~nan_mask)[0] + if len(valid_indices) > 0: + # Fill ALL invalid values at the beginning with the first valid value + first_valid_idx = valid_indices[0] + if first_valid_idx > 0: + array_copy[:first_valid_idx] = array_copy[first_valid_idx] + + # Fill ALL invalid values at the end with the last valid value + last_valid_idx = valid_indices[-1] + if last_valid_idx < len(array_copy) - 1: + array_copy[last_valid_idx+1:] = array_copy[last_valid_idx] + + # Step 2: Update nan_mask after edge filling to identify remaining interior gaps + nan_mask = np.isnan(array_copy) + + # Step 3: Handle any remaining interior invalid values + if np.any(nan_mask): + # Extract known points and values for interpolation + x_known = P_MPa[~nan_mask] + y_known = array_copy[~nan_mask] + + # Check if we have sufficient points for spline interpolation + if len(x_known) >= 2: + # Use linear interpolation (k=1) for robustness + spline = interpolate.make_interp_spline(x_known, y_known, k=1) + # Only interpolate for the remaining invalid interior points + invalid_indices = np.where(nan_mask)[0] + array_copy[invalid_indices] = spline(P_MPa[invalid_indices]) + else: + # Fallback to nearest neighbor if insufficient points for interpolation + for i in np.where(nan_mask)[0]: + # Find distances to all valid points + valid_indices = np.where(~nan_mask)[0] + distances = np.abs(valid_indices - i) + # Find the index of the nearest valid point + nearest_valid_idx = valid_indices[np.argmin(distances)] + # Fill with nearest neighbor value + array_copy[i] = array_copy[nearest_valid_idx] + + interpolated_arrays.append(array_copy) + else: + # Fallback: If no valid data exists in this array, fill with zeros + interpolated_arrays.append(np.zeros_like(array_copy)) + return tuple(interpolated_arrays) def extract_species_from_reaction(species_dict, reaction_dict): diff --git a/PlanetProfile/Thermodynamics/RefProfiles/RefProfiles.py b/PlanetProfile/Thermodynamics/RefProfiles/RefProfiles.py index f50f550b..6effe293 100644 --- a/PlanetProfile/Thermodynamics/RefProfiles/RefProfiles.py +++ b/PlanetProfile/Thermodynamics/RefProfiles/RefProfiles.py @@ -44,7 +44,8 @@ def CalcRefProfiles(PlanetList, Params): for i, w_ppt in enumerate(wList): EOSref = GetOceanEOS(Planet.Ocean.comp, w_ppt, Params.Pref_MPa[Planet.Ocean.comp], Tref_K, Planet.Ocean.MgSO4elecType, rhoType=Planet.Ocean.MgSO4rhoType, scalingType=Planet.Ocean.MgSO4scalingType, phaseType='lookup', - EXTRAP=Params.EXTRAP_REF, FORCE_NEW=Params.FORCE_EOS_RECALC, MELT=True) + EXTRAP=Params.EXTRAP_REF, FORCE_NEW=Params.FORCE_EOS_RECALC, MELT=True, kThermConst_WmK=Planet.Ocean.kThermWater_WmK) + if EOSref.propsPmax < Pmax or EOSref.Pmax < Pmax: Params.Pref_MPa[Planet.Ocean.comp] = np.linspace(Params.Pref_MPa[Planet.Ocean.comp][0], np.minimum(EOSref.propsPmax, EOSref.Pmax), Params.nRefPts[Planet.Ocean.comp]) diff --git a/PlanetProfile/Thermodynamics/Seafreeze/SeafreezeProps.py b/PlanetProfile/Thermodynamics/Seafreeze/SeafreezeProps.py new file mode 100644 index 00000000..f181ca80 --- /dev/null +++ b/PlanetProfile/Thermodynamics/Seafreeze/SeafreezeProps.py @@ -0,0 +1,133 @@ +import numpy as np +import seafreeze.seafreeze as sfz +# Use direct seafreeze import for efficiency +from seafreeze.seafreeze import defpath, _get_tdvs, _is_scatter, phases as seafreeze_phases +from mlbspline import load +from sqlalchemy.sql import false +from PlanetProfile.Utilities.defineStructs import Constants, EOSlist +from itertools import repeat +def IceSeaFreezeProps(PTgrid, phaseName): + # Get boundarys of P_MPa and T_K grid to generate a unique tag + sfz_Pmin = np.min(PTgrid[0]) + sfz_Pmax = np.max(PTgrid[0]) + sfz_Tmin = np.min(PTgrid[1]) + sfz_Tmax = np.max(PTgrid[1]) + # Ensure space is linear so that we can use the already loaded EOS if it exists + PTgrid[0] = np.linspace(sfz_Pmin, sfz_Pmax, PTgrid[0].size) + PTgrid[1] = np.linspace(sfz_Tmin, sfz_Tmax, PTgrid[1].size) + if PTgrid[0].size == 1: + sfz_deltaP = 0 + else: + sfz_deltaP = np.round(np.mean(np.diff(PTgrid[0])), 3) + sfz_deltaT = np.round(np.mean(np.diff(PTgrid[1])), 3) + # Generate unique tags for the seafreeze lookup tables so we only have to generate them once + seafreezeRange = (sfz_Pmin, sfz_Pmax, sfz_Tmin, sfz_Tmax, sfz_deltaP, sfz_deltaT) + alreadyLoaded = False + if phaseName in EOSlist.loaded.keys(): + if EOSlist.ranges[phaseName] == seafreezeRange: + alreadyLoaded = True + sfzOut = EOSlist.loaded[phaseName] + else: + alreadyLoadedPmin = EOSlist.ranges[phaseName][0] + alreadyLoadedPmax = EOSlist.ranges[phaseName][1] + alreadyLoadedTmin = EOSlist.ranges[phaseName][2] + alreadyLoadedTmax = EOSlist.ranges[phaseName][3] + alreadyLoadedDeltaP = EOSlist.ranges[phaseName][4] + alreadyLoadedDeltaT = EOSlist.ranges[phaseName][5] + # Check if we have already loaded a higher fidelity grid + noP = sfz_Pmin < alreadyLoadedPmin or sfz_Pmax > alreadyLoadedPmax + noT = sfz_Tmin < alreadyLoadedTmin or sfz_Tmax > alreadyLoadedTmax + noDeltaP = sfz_deltaP < alreadyLoadedDeltaP + noDeltaT = sfz_deltaT < alreadyLoadedDeltaT + if not noP and not noT and not noDeltaP and not noDeltaT: + alreadyLoaded = True + sfzOut = EOSlist.loaded[phaseName] + PTgrid[0] = np.linspace(alreadyLoadedPmin, alreadyLoadedPmax, sfzOut.G.shape[0]) + PTgrid[1] = np.linspace(alreadyLoadedTmin, alreadyLoadedTmax, sfzOut.G.shape[1]) + + if not alreadyLoaded: + sfzOut = sfz.seafreeze(PTgrid, phaseName) + EOSlist.loaded[phaseName] = sfzOut + EOSlist.ranges[phaseName] = seafreezeRange + return sfzOut, PTgrid[0], PTgrid[1] + +def GenerateSeafreezeChemicalPotentials(P_MPa, T_K, doPureWater = False): + """ + This function generates the minimum ice chemical potential and the most stable ice phase for a given PT grid. + It also generates the pure water chemical potential if requested. Functiosn are saved to EOSlist and the tags to access them are returned. + This is purposely done to avoid recopying large numpy grids, which can be expensive and take up large amounts of temporary memory space, especially for high fidelity grids. + """ + # Put P_MPa and T_K grid points into format compatible with seafreeze + evalPts_sfz = np.array([P_MPa, T_K], dtype=object) + # Get boundarys of P_MPa and T_K grid to generate a unique tag + sfz_Pmin = np.min(P_MPa) + sfz_Pmax = np.max(P_MPa) + sfz_Tmin = np.min(T_K) + sfz_Tmax = np.max(T_K) + if P_MPa.size == 1: + sfz_deltaP = 0 + else: + sfz_deltaP = np.round(np.mean(np.diff(P_MPa)), 3) + sfz_deltaT = np.round(np.mean(np.diff(T_K)), 3) + # Generate unique tags for the seafreeze lookup tables so we only have to generate them once + seafreezeRange = f'Pmin_{sfz_Pmin}_Pmax_{sfz_Pmax}_Tmin_{sfz_Tmin}_Tmax_{sfz_Tmax}_deltaP_{sfz_deltaP}_deltaT_{sfz_deltaT}' + seafreezeMuTag = f'mu_J_mol_{seafreezeRange}' + seafreezePureWaterMuTag = f'mu_J_mol_pure_water_{seafreezeRange}' + seafreezeIcePhaseTag = f'icePhase_{seafreezeRange}' + # Check if we have already loaded the minimum ice chemical potential grid and its associated most stable phase grid + if seafreezeMuTag in EOSlist.loaded.keys() and seafreezeIcePhaseTag in EOSlist.loaded.keys(): + pass + else: + # Check if we need to calculate the pure water chemical potential + calcPureWater = True if doPureWater and seafreezePureWaterMuTag not in EOSlist.loaded.keys() else False + # Check if we need to calculate the high-pressure ice chemical potentials + doHPIces = sfz_Pmax > Constants.PminHPices_MPa + # Determine number of ice phases to consider + if doHPIces: + num_ice_phases = len(Constants.seafreeze_ice_phases) + else: + num_ice_phases = 1 + # Create array to store chemical potentials of pure water and for all ice phases + muWater = np.full((P_MPa.size, T_K.size), np.nan) + muIces = np.full((P_MPa.size, T_K.size, num_ice_phases), np.nan) + # Calculate chemical potential for each ice phase + for phase, name in Constants.seafreeze_ice_phases.items(): + # Skip high-pressure ices if not needed or skip pure water if not needed + if (phase > 1 and not doHPIces) or (phase == 0 and not calcPureWater): + continue + # Calculate chemical potential using seafreeze + if P_MPa.size == 1 or T_K.size == 1: + # Handle single value arrays + sfz_PT = np.array([P_MPa, T_K], dtype=object) + sfzMu = sfz.getProp(sfz_PT, name).G * Constants.m_gmol['H2O'] / 1000 + else: + phasedesc = seafreeze_phases[name] + sp = load.loadSpline(defpath, phasedesc.sp_name) + isscatter = _is_scatter(evalPts_sfz) + sfzMu = _get_tdvs(sp, evalPts_sfz, isscatter, 'G').G * Constants.m_gmol['H2O'] / 1000 + # Save pure water chemical potential separately + if phase == 0: + muWater = sfzMu + else: + # Save chemical potential for other ice phases to 3d array to compare + muIces[:, :, phase-1] = sfzMu + + # Find most stable ice phase and minimum chemical potential + minIce_mu_Jmol_all = np.full((P_MPa.size, T_K.size), np.nan) + stableIcePhase = np.zeros((P_MPa.size, T_K.size), dtype=np.uint8) + if doHPIces: + # Find minimum chemical potential across all ice phases + minIce_mu_Jmol_all = np.nanmin(muIces, axis=-1) + # Only process valid (non-NaN) points + valid_mask = ~np.isnan(minIce_mu_Jmol_all) + # Store minimum chemical potential and corresponding phase + stableIcePhase[valid_mask] = np.nanargmin(muIces[valid_mask], axis=-1) + 1 + else: + # Only ice Ih is stable at low pressures + minIce_mu_Jmol_all = muIces[:, :, 0] + stableIcePhase = np.ones((P_MPa.size, T_K.size), dtype=np.uint8) + # Save to EOSlist so we do not have to recalculate them next time (good for generating many EOS objects with same PT grid) + EOSlist.loaded[seafreezeMuTag] = minIce_mu_Jmol_all + EOSlist.loaded[seafreezeIcePhaseTag] = stableIcePhase + EOSlist.loaded[seafreezePureWaterMuTag] = muWater + return seafreezeMuTag, seafreezeIcePhaseTag, seafreezePureWaterMuTag \ No newline at end of file diff --git a/PlanetProfile/Thermodynamics/Seismic.py b/PlanetProfile/Thermodynamics/Seismic.py index 785ebd4a..450e875b 100644 --- a/PlanetProfile/Thermodynamics/Seismic.py +++ b/PlanetProfile/Thermodynamics/Seismic.py @@ -3,9 +3,10 @@ from PlanetProfile.Thermodynamics.HydroEOS import GetIceEOS, GetOceanEOS from PlanetProfile.Utilities.Indexing import GetPhaseIndices from PlanetProfile.Thermodynamics.InnerEOS import TsolidusHirschmann2000 -from PlanetProfile.Utilities.defineStructs import Constants, EOSlist +from PlanetProfile.Utilities.defineStructs import Constants, EOSlist, Timing from PlanetProfile.Utilities.PPversion import ppVerNum import logging +import time # Assign logger log = logging.getLogger('PlanetProfile') @@ -16,20 +17,23 @@ def SeismicCalcs(Planet, Params): Assigns Planet attributes: Seismic.VP_kms, Seismic.VS_kms, Seismic.QS, Seismic.KS_GPa, Seismic.GS_GPa """ + Timing.setFunctionTime(time.time()) # Initialize arrays Planet.Seismic.VP_kms, Planet.Seismic.VS_kms, Planet.Seismic.QS, Planet.Seismic.KS_GPa, \ Planet.Seismic.GS_GPa = (np.zeros(Planet.Steps.nTotal) for _ in range(5)) - if Params.CALC_SEISMIC and Planet.Do.VALID: + if Params.CALC_SEISMIC and (Planet.Do.VALID or (Params.ALLOW_BROKEN_MODELS and Planet.Do.STILL_CALCULATE_BROKEN_PROPERTIES)): # Make sure the necessary EOSs have been loaded (mainly only important in parallel ExploreOgram runs) - if not Planet.Do.NO_H2O and Planet.Ocean.EOS.key not in EOSlist.loaded.keys(): + if not (Planet.Do.NO_H2O or Planet.Do.NO_OCEAN) and Planet.Ocean.EOS.key not in EOSlist.loaded.keys(): POcean_MPa = np.arange(Planet.PfreezeLower_MPa, Planet.Ocean.PHydroMax_MPa, Planet.Ocean.deltaP) TOcean_K = np.arange(Planet.Bulk.Tb_K, Planet.Ocean.THydroMax_K, Planet.Ocean.deltaT) Planet.Ocean.EOS = GetOceanEOS(Planet.Ocean.comp, Planet.Ocean.wOcean_ppt, POcean_MPa, TOcean_K, Planet.Ocean.MgSO4elecType, rhoType=Planet.Ocean.MgSO4rhoType, scalingType=Planet.Ocean.MgSO4scalingType, FORCE_NEW=Params.FORCE_EOS_RECALC, phaseType=Planet.Ocean.phaseType, EXTRAP=Params.EXTRAP_OCEAN, - sigmaFixed_Sm=Planet.Ocean.sigmaFixed_Sm) + sigmaFixed_Sm=Planet.Ocean.sigmaFixed_Sm, kThermConst_WmK=Planet.Ocean.kThermWater_WmK, + propsStepReductionFactor=Planet.Ocean.propsStepReductionFactor) + indsLiq, indsI, indsIwet, indsII, indsIIund, indsIII, indsIIIund, indsV, indsVund, indsVI, indsVIund, \ indsClath, indsClathWet, indsMixedClathrateIh, indsMixedClathrateII, indsMixedClathrateIII, indsMixedClathrateV, indsMixedClathrateVI, \ @@ -51,7 +55,7 @@ def SeismicCalcs(Planet, Params): Pclosure_MPa=Planet.Ocean.Pclosure_MPa[icePhase], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE[icePhase], - ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT) + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, kThermConst_WmK=Planet.Ocean.kThermIce_WmK, doConstantProps = Planet.Do.CONSTANTPROPSEOS) Planet.Seismic.VP_kms[indsAllI], Planet.Seismic.VS_kms[indsAllI], Planet.Seismic.KS_GPa[indsAllI], \ Planet.Seismic.GS_GPa[indsAllI] = Planet.Ocean.surfIceEOS['Ih'].fn_Seismic(Planet.P_MPa[indsAllI], Planet.T_K[indsAllI]) @@ -72,7 +76,7 @@ def SeismicCalcs(Planet, Params): Pclosure_MPa=Planet.Ocean.Pclosure_MPa[icePhase], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE[icePhase], - ClathDissoc=Planet.Ocean.ClathDissoc) + ClathDissoc=Planet.Ocean.ClathDissoc, kThermConst_WmK=Planet.Ocean.kThermIce_WmK) Planet.Seismic.VP_kms[indsAllClath], Planet.Seismic.VS_kms[indsAllClath], \ Planet.Seismic.KS_GPa[indsAllClath], Planet.Seismic.GS_GPa[indsAllClath] \ @@ -120,7 +124,7 @@ def SeismicCalcs(Planet, Params): phiTop_frac=Planet.Ocean.phiMax_frac[icePhase], Pclosure_MPa=Planet.Ocean.Pclosure_MPa[icePhase], phiMin_frac=Planet.Ocean.phiMin_frac, - EXTRAP=Params.EXTRAP_ICE[icePhase]) + EXTRAP=Params.EXTRAP_ICE[icePhase], kThermConst_WmK=Planet.Ocean.kThermIce_WmK) Planet.Seismic.VP_kms[indsIIund], Planet.Seismic.VS_kms[indsIIund], \ Planet.Seismic.KS_GPa[indsIIund], Planet.Seismic.GS_GPa[indsIIund] \ @@ -135,7 +139,7 @@ def SeismicCalcs(Planet, Params): phiTop_frac=Planet.Ocean.phiMax_frac[icePhase], Pclosure_MPa=Planet.Ocean.Pclosure_MPa[icePhase], phiMin_frac=Planet.Ocean.phiMin_frac, - EXTRAP=Params.EXTRAP_ICE[icePhase]) + EXTRAP=Params.EXTRAP_ICE[icePhase], kThermConst_WmK=Planet.Ocean.kThermIce_WmK) Planet.Seismic.VP_kms[indsII], Planet.Seismic.VS_kms[indsII], \ Planet.Seismic.KS_GPa[indsII], Planet.Seismic.GS_GPa[indsII] \ @@ -157,7 +161,7 @@ def SeismicCalcs(Planet, Params): phiTop_frac=Planet.Ocean.phiMax_frac[icePhase], Pclosure_MPa=Planet.Ocean.Pclosure_MPa[icePhase], phiMin_frac=Planet.Ocean.phiMin_frac, - EXTRAP=Params.EXTRAP_ICE[icePhase]) + EXTRAP=Params.EXTRAP_ICE[icePhase], kThermConst_WmK=Planet.Ocean.kThermIce_WmK) Planet.Seismic.VP_kms[indsIIIund], Planet.Seismic.VS_kms[indsIIIund], \ Planet.Seismic.KS_GPa[indsIIIund], Planet.Seismic.GS_GPa[indsIIIund] \ @@ -172,7 +176,7 @@ def SeismicCalcs(Planet, Params): phiTop_frac=Planet.Ocean.phiMax_frac[icePhase], Pclosure_MPa=Planet.Ocean.Pclosure_MPa[icePhase], phiMin_frac=Planet.Ocean.phiMin_frac, - EXTRAP=Params.EXTRAP_ICE[icePhase]) + EXTRAP=Params.EXTRAP_ICE[icePhase], kThermConst_WmK=Planet.Ocean.kThermIce_WmK) Planet.Seismic.VP_kms[indsIII], Planet.Seismic.VS_kms[indsIII], \ Planet.Seismic.KS_GPa[indsIII], Planet.Seismic.GS_GPa[indsIII] \ @@ -194,7 +198,7 @@ def SeismicCalcs(Planet, Params): phiTop_frac=Planet.Ocean.phiMax_frac[icePhase], Pclosure_MPa=Planet.Ocean.Pclosure_MPa[icePhase], phiMin_frac=Planet.Ocean.phiMin_frac, - EXTRAP=Params.EXTRAP_ICE[icePhase]) + EXTRAP=Params.EXTRAP_ICE[icePhase], kThermConst_WmK=Planet.Ocean.kThermIce_WmK) Planet.Seismic.VP_kms[indsVund], Planet.Seismic.VS_kms[indsVund], \ Planet.Seismic.KS_GPa[indsVund], Planet.Seismic.GS_GPa[indsVund] \ @@ -209,7 +213,7 @@ def SeismicCalcs(Planet, Params): phiTop_frac=Planet.Ocean.phiMax_frac[icePhase], Pclosure_MPa=Planet.Ocean.Pclosure_MPa[icePhase], phiMin_frac=Planet.Ocean.phiMin_frac, - EXTRAP=Params.EXTRAP_ICE[icePhase]) + EXTRAP=Params.EXTRAP_ICE[icePhase], kThermConst_WmK=Planet.Ocean.kThermIce_WmK) Planet.Seismic.VP_kms[indsV], Planet.Seismic.VS_kms[indsV], \ Planet.Seismic.KS_GPa[indsV], Planet.Seismic.GS_GPa[indsV] \ @@ -231,7 +235,7 @@ def SeismicCalcs(Planet, Params): phiTop_frac=Planet.Ocean.phiMax_frac[icePhase], Pclosure_MPa=Planet.Ocean.Pclosure_MPa[icePhase], phiMin_frac=Planet.Ocean.phiMin_frac, - EXTRAP=Params.EXTRAP_ICE[icePhase]) + EXTRAP=Params.EXTRAP_ICE[icePhase], kThermConst_WmK=Planet.Ocean.kThermIce_WmK) Planet.Seismic.VP_kms[indsVIund], Planet.Seismic.VS_kms[indsVIund], \ Planet.Seismic.KS_GPa[indsVIund], Planet.Seismic.GS_GPa[indsVIund] \ @@ -246,7 +250,7 @@ def SeismicCalcs(Planet, Params): phiTop_frac=Planet.Ocean.phiMax_frac[icePhase], Pclosure_MPa=Planet.Ocean.Pclosure_MPa[icePhase], phiMin_frac=Planet.Ocean.phiMin_frac, - EXTRAP=Params.EXTRAP_ICE[icePhase]) + EXTRAP=Params.EXTRAP_ICE[icePhase], kThermConst_WmK=Planet.Ocean.kThermIce_WmK) Planet.Seismic.VP_kms[indsVI], Planet.Seismic.VS_kms[indsVI], \ Planet.Seismic.KS_GPa[indsVI], Planet.Seismic.GS_GPa[indsVI] \ @@ -307,7 +311,7 @@ def SeismicCalcs(Planet, Params): if np.any(Planet.Seismic.QS > Planet.Seismic.QSmax): log.debug(f'Resetting unnecessarily high QS values to max value: {Planet.Seismic.QSmax}') Planet.Seismic.QS[Planet.Seismic.QS > Planet.Seismic.QSmax] = Planet.Seismic.QSmax - + Timing.printFunctionTimeDifference('SeismicCalcs()', time.time()) return Planet diff --git a/PlanetProfile/Thermodynamics/ThermalProfiles/Convection.py b/PlanetProfile/Thermodynamics/ThermalProfiles/Convection.py index 471d7183..9688486f 100644 --- a/PlanetProfile/Thermodynamics/ThermalProfiles/Convection.py +++ b/PlanetProfile/Thermodynamics/ThermalProfiles/Convection.py @@ -2,10 +2,12 @@ import logging from PlanetProfile.Thermodynamics.Geophysical import PropagateConduction, EvalLayerProperties, \ PorosityCorrectionVacIce, PorosityCorrectionFilledIce, PropagateAdiabaticSolid, \ - PropagateAdiabaticPorousVacIce, PropagateAdiabaticPorousFilledIce + PropagateAdiabaticPorousVacIce, PropagateAdiabaticPorousFilledIce, PropogateConductionFromDepth, \ + PropagateAdiabaticSolidFromDepth from PlanetProfile.Utilities.Indexing import PhaseConv +from PlanetProfile.Thermodynamics.HydroEOS import GetIceEOS, GetOceanEOS, GetTfreeze from PlanetProfile.Thermodynamics.ThermalProfiles.ThermalProfiles import ConvectionDeschampsSotin2001, \ - kThermHobbs1974, kThermMelinder2007 + kThermHobbs1974, kThermMelinder2007, ConvectionPetricca2024 from PlanetProfile.Utilities.defineStructs import Constants # Assign logger @@ -19,146 +21,248 @@ def IceIConvectSolid(Planet, Params): Assigns Planet attributes: Tconv_K, etaConv_Pas, eLid_m, deltaTBL_m, QfromMantle_W, all physical layer arrays """ - - log.debug('Applying solid-state convection to surface ice I based on Deschamps and Sotin (2001).') - zbI_m = Planet.z_m[Planet.Steps.nIbottom] - # Get "middle" pressure - Pmid_MPa = (Planet.PbI_MPa + Planet.P_MPa[0]) / 2 - # Get a lower estimate for thermal conductivity if there's no clathrate lid, to be more consistent - # with the Matlab implementation of Deschamps and Sotin (2001). The thermal conductivity of clathrates - # is essentially fixed at 0.5 W/(m K), so doing this operation when the top layer is clathrate - # gives us what we want in that case, too. - phaseTop = PhaseConv(Planet.phase[0]) - Planet.kTherm_WmK[0] = Planet.Ocean.surfIceEOS[phaseTop].fn_kTherm_WmK(Pmid_MPa, Planet.Bulk.Tb_K) - - # Run calculations to get convection layer parameters - Planet.Tconv_K, Planet.etaConv_Pas, Planet.eLid_m, Planet.Dconv_m, Planet.deltaTBL_m, Planet.Ocean.QfromMantle_W, \ - Planet.RaConvect, Planet.RaCrit = \ - ConvectionDeschampsSotin2001(Planet.T_K[0], Planet.r_m[0], Planet.kTherm_WmK[0], Planet.Bulk.Tb_K, - zbI_m, Planet.g_ms2[0], Pmid_MPa, Planet.Ocean.EOS, - Planet.Ocean.surfIceEOS['Ih'], 1, Planet.Do.EQUIL_Q) - - log.debug(f'Ice I convection parameters:\n T_convect = {Planet.Tconv_K:.3f} K,\n' + - f' Viscosity etaConvect = {Planet.etaConv_Pas:.3e} Pa*s,\n' + - f' Conductive lid thickness eLid = {Planet.eLid_m/1e3:.1f} km,\n' + - f' Convecting layer thickness Dconv = {Planet.Dconv_m/1e3:.1f} km,\n' + - f' Lower TBL thickness deltaTBL = {Planet.deltaTBL_m/1e3:.1f} km,\n' + - f' Rayleigh number Ra = {Planet.RaConvect:.3e}.') - - # Check for whole-lid conduction - if(zbI_m <= Planet.eLid_m + Planet.deltaTBL_m): - log.info(f'Ice shell thickness ({zbI_m/1e3:.1f} km) is less than that of the thermal ' + - 'boundary layers--convection is absent. Applying whole-shell conductive profile.') - Planet.eLid_m = zbI_m - Planet.Dconv_m = 0.0 - Planet.deltaTBL_m = 0.0 - - # Recalculate heat flux, as it will be too high for conduction-only: - qSurf_Wm2 = (Planet.T_K[1] - Planet.T_K[0]) / (Planet.r_m[0] - Planet.r_m[1]) * Planet.kTherm_WmK[0] - Planet.Ocean.QfromMantle_W = qSurf_Wm2 * 4*np.pi * Planet.Bulk.R_m**2 - - # We leave the remaining quantities as initially assigned, - # as we find the initial profile assuming conduction only. + if Planet.Do.NON_SELF_CONSISTENT: + log.debug('Applying solid-state convection to surface ice I based on Petricca et al. (2024).') + zbI_m = Planet.dzIceI_km * 1e3 + + Planet.Tconv_K, Planet.etaConv_Pas, Planet.eLid_m, Planet.Dconv_m, Planet.deltaTBL_m, Planet.Ocean.QfromMantle_W, \ + Planet.RaConvect, Planet.RaCrit = \ + ConvectionPetricca2024(Planet.Bulk.Tb_K, Planet.T_K[0], zbI_m, Planet.Ocean.Eact_kJmol['Ih'], Planet.etaMelt_Pas, Planet.rho_kgm3[0], Planet.alpha_pK[0], Planet.kTherm_WmK[0], Planet.Cp_JkgK[0], Planet.g_ms2[0], 1, Planet.r_m[0]) + + log.debug(f'Ice I convection parameters:\n T_convect = {Planet.Tconv_K:.3f} K,\n' + + f' Viscosity etaConvect = {Planet.etaConv_Pas:.3e} Pa*s,\n' + + f' Conductive lid thickness eLid = {Planet.eLid_m/1e3:.1f} km,\n' + + f' Convecting layer thickness Dconv = {Planet.Dconv_m/1e3:.1f} km,\n' + + f' Rayleigh number Ra = {Planet.RaConvect:.3e}.') + + # Check for whole-lid conduction + if(zbI_m <= Planet.eLid_m): + log.info(f'Ice shell thickness ({zbI_m/1e3:.1f} km) is less than that of the thermal ' + + 'boundary layers--convection is absent. Applying whole-shell conductive profile.') + Planet.eLid_m = zbI_m + Planet.Dconv_m = 0.0 + + + else: + # If there is convection, then we need to split the number of ice layers in half + # (in non self consistent mode, we do this since we already have a minimal amount of ice layer, + # rather than iterating like we do in self consistent modes) + nConvect = Planet.Steps.nIbottom // 2 + nConduct = Planet.Steps.nIbottom - nConvect + # Set logical array for convective ice layers + Planet.Steps.iConv[nConduct:nConvect] = True + # If we are doing constant properties, we need to re-get EOS with convective properties + if Planet.Do.CONSTANTPROPSEOS: + Planet.Ocean.constantProperties['Ih'] = {'Tconv_K': Planet.Tconv_K, + 'rho_kgm3': [Planet.Ocean.rhoCondMean_kgm3['Ih'], Planet.Ocean.rhoConvMean_kgm3['Ih']], + 'Cp_JkgK': [Constants.Cp_JkgK['Ih'], Constants.Cp_JkgK['Ih']], + 'alpha_pK': [Constants.alphaIce_pK['Ih'], Constants.alphaIce_pK['Ih']], + 'kTherm_WmK': [Planet.Ocean.kThermIce_WmK['Ih'], Planet.Ocean.kThermIce_WmK['Ih']], + 'VP_GPa': [Constants.VP_GPa[1], Constants.VP_GPa[1]], + 'GS_GPa': [Planet.Ocean.GScondMean_GPa['Ih'], Planet.Ocean.GScondMean_GPa['Ih']], + 'VS_GPa': [Constants.VS_GPa[1], Constants.VS_GPa[1]], + 'KS_GPa': [Constants.KS_GPa[1], Constants.KS_GPa[1]], + 'sigma_Sm': [Planet.Ocean.sigmaIce_Sm['Ih'], Planet.Ocean.sigmaIce_Sm['Ih']], + 'eta_Pas': [Constants.etaIce_Pas[0], Planet.etaConv_Pas], + } + Planet.Ocean.surfIceEOS['Ih'] = GetIceEOS(Planet.P_MPa[:Planet.Steps.nIbottom+1], Planet.T_K[:Planet.Steps.nIbottom+1], 'Ih', EXTRAP=Params.EXTRAP_ICE['Ih'], + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, kThermConst_WmK=Planet.Ocean.kThermIce_WmK, + mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}, + doConstantProps=True, + constantProperties=Planet.Ocean.constantProperties['Ih']) + # Set depth of conductive and convective layers + Planet.z_m[:nConduct+1] = np.linspace(0, Planet.eLid_m, nConduct+1) + Planet.z_m[nConduct:nConduct + nConvect+1] = np.linspace(Planet.eLid_m, zbI_m, nConvect+1) + Planet.Steps.iConv[nConduct:nConvect+nConvect] = True + + log.debug('Modeling ice I conduction in stagnant lid...') + + # Propogate conductive layers - Ensure Tbot_K is slightly less than Tconv_K to avoid numerical issues + Planet = PropogateConductionFromDepth(Planet, Params, 0, nConduct, Planet.Tconv_K, Planet.Ocean.surfIceEOS['Ih']) + + log.debug('Stagnant lid conductive profile complete. Modeling ice I convecting layer...') + + + # Propagate convective layers + Planet = PropagateAdiabaticSolidFromDepth(Planet, Params, nConduct, nConduct+nConvect, Planet.Ocean.surfIceEOS['Ih']) + # In the case where we only model one ice conductive layer, we need to set the temperature of the top of th convective layer to the convective temperature manually + if nConduct == 1: + Planet.T_K[nConduct] = Planet.Tconv_K + log.debug(f'Only modeled 1 conductive layer, which can lead to a temperature discontinuity at the convective layer. Setting temperature at start of convective layer to Planet.Tconv_K: {Planet.Tconv_K:.3f} K.') + log.debug(f'il: {nConduct:d}; P_MPa: {Planet.P_MPa[nConduct]:.3f}; T_K: {Planet.T_K[nConduct]:.3f}; phase: {Planet.phase[nConduct]:d}') + log.debug('Ice I convecting layer complete. Propagating starting point for next layer...') + + # Now we have the bottom pressure + Planet.PbI_MPa = Planet.P_MPa[nConvect] + # Finally, recalculate Tb_K with the new bottom pressure + if Planet.Ocean.comp == 'none': + Planet.Bulk.Tb_K = Constants.T0 + else: + # Query bulk temperature and EC from ocean composition + Pbottom_MPa = Planet.dzIceI_km * 1e3 * Planet.g_ms2[0] * Planet.Ocean.surfIceEOS['Ih'].fn_rho_kgm3(Planet.Bulk.Psurf_MPa, Planet.Bulk.Tsurf_K) + Pmelt_MPa = np.linspace(Pbottom_MPa - 0.01, Planet.Bulk.Psurf_MPa + 0.01, 3) + Tmelt_K = np.linspace(Planet.TfreezeLower_MPa, Planet.TfreezeUpper_MPa, Planet.TfreezeRes_K) + Planet.Ocean.meltEOS = GetOceanEOS(Planet.Ocean.comp, Planet.Ocean.wOcean_ppt, Pmelt_MPa, Tmelt_K) + Planet.Bulk.Tb_K = GetTfreeze(Planet.Ocean.meltEOS, Planet.Pb_MPa, Planet.TfreezeLower_K, TRes_K=Planet.TfreezeRes_K) + # Lastly, we set the bottom temperature to the beginning layer of the convective layer + Planet.T_K[Planet.Steps.nIbottom] = Planet.Bulk.Tb_K + log.debug(f'il: {Planet.Steps.nIbottom:d}; P_MPa: {Planet.P_MPa[Planet.Steps.nIbottom]:.3f}; T_K: {Planet.T_K[Planet.Steps.nIbottom]:.3f}') + log.debug('Ice I convection calculations complete.') + else: - # Now model conductive + convective layers - # Get layer transition indices from previous profile - try: - nConduct = next(i[0] for i,val in np.ndenumerate(Planet.z_m) if val > Planet.eLid_m) - except StopIteration: - raise RuntimeError('Failed to find any depth indices for upper TBL of ice III. Try increasing Steps.nIceVLitho.') - try: - nConvect = next(i[0] for i,val in np.ndenumerate(Planet.z_m) if val > zbI_m - Planet.deltaTBL_m) - nConduct - except StopIteration: - raise RuntimeError('Failed to find any depth indices for lower TBL of ice III. Try increasing Steps.nIceVLitho.') - indsTBL = range(nConduct + nConvect, Planet.Steps.nIbottom+1) - # Get pressure at the convecting transition - PconvTop_MPa = Planet.P_MPa[nConduct] - - # Reset profile of upper layers, keeping pressure values fixed - if Planet.Do.CLATHRATE: - log.debug('Evaluating clathrate layers in conductive lid.') - - if Planet.Bulk.clathType == 'top': - if (Planet.eLid_m < Planet.zClath_m): - Planet.Bulk.clathMaxDepth_m = Planet.eLid_m - log.debug('Clathrate lid thickness was greater than the conductive lid thickness. ' + - 'Planet.Bulk.clathMaxDepth_m has been reduced to be equal to the conductive lid thickness.') - if Planet.PbClathMax_MPa > PconvTop_MPa: - Planet.PbClathMax_MPa = PconvTop_MPa - Planet.TclathTrans_K = Planet.Tconv_K - Planet.P_MPa[:Planet.Steps.nClath] = np.linspace(Planet.P_MPa[0], PconvTop_MPa, Planet.Steps.nClath+1)[:-1] - Planet.P_MPa[Planet.Steps.nClath:Planet.Steps.nIbottom+1] = \ - np.linspace(PconvTop_MPa, Planet.PbI_MPa, Planet.Steps.nIceI+1) - # Reassign T profile to be consistent with conduction - PlidRatios = Planet.P_MPa[:Planet.Steps.nClath+1] / PconvTop_MPa - Planet.T_K[:Planet.Steps.nClath+1] = Planet.Tconv_K**(PlidRatios) * Planet.T_K[0]**(1 - PlidRatios) + log.debug('Applying solid-state convection to surface ice I based on Deschamps and Sotin (2001).') + zbI_m = Planet.z_m[Planet.Steps.nIbottom] + # Get "middle" pressure + Pmid_MPa = (Planet.PbI_MPa + Planet.P_MPa[0]) / 2 + # Get a lower estimate for thermal conductivity if there's no clathrate lid, to be more consistent + # with the Matlab implementation of Deschamps and Sotin (2001). The thermal conductivity of clathrates + # is essentially fixed at 0.5 W/(m K), so doing this operation when the top layer is clathrate + # gives us what we want in that case, too. + phaseTop = PhaseConv(Planet.phase[0]) + Planet.kTherm_WmK[0] = Planet.Ocean.surfIceEOS[phaseTop].fn_kTherm_WmK(Pmid_MPa, Planet.Bulk.Tb_K) + + # Run calculations to get convection layer parameters + Planet.Tconv_K, Planet.etaConv_Pas, Planet.eLid_m, Planet.Dconv_m, Planet.deltaTBL_m, Planet.Ocean.QfromMantle_W, \ + Planet.RaConvect, Planet.RaCrit = \ + ConvectionDeschampsSotin2001(Planet.T_K[0], Planet.r_m[0], Planet.kTherm_WmK[0], Planet.Bulk.Tb_K, + zbI_m, Planet.g_ms2[0], Pmid_MPa, Planet.Ocean.EOS, + Planet.Ocean.surfIceEOS['Ih'], 1, Planet.Do.EQUIL_Q, Planet.Ocean.Eact_kJmol) + + log.debug(f'Ice I convection parameters:\n T_convect = {Planet.Tconv_K:.3f} K,\n' + + f' Viscosity etaConvect = {Planet.etaConv_Pas:.3e} Pa*s,\n' + + f' Conductive lid thickness eLid = {Planet.eLid_m/1e3:.1f} km,\n' + + f' Convecting layer thickness Dconv = {Planet.Dconv_m/1e3:.1f} km,\n' + + f' Lower TBL thickness deltaTBL = {Planet.deltaTBL_m/1e3:.1f} km,\n' + + f' Rayleigh number Ra = {Planet.RaConvect:.3e}.') + + # Update the viscosity of the Ice Ih EOS to use the convective viscosity + Planet.Ocean.surfIceEOS['Ih'].updateConvectionViscosity(Planet.etaConv_Pas, Planet.Tconv_K) + + # Check for whole-lid conduction + if(zbI_m <= Planet.eLid_m + Planet.deltaTBL_m): + log.info(f'Ice shell thickness ({zbI_m/1e3:.1f} km) is less than that of the thermal ' + + 'boundary layers--convection is absent. Applying whole-shell conductive profile.') + Planet.eLid_m = zbI_m + Planet.Dconv_m = 0.0 + Planet.deltaTBL_m = 0.0 + + # Recalculate heat flux, as it will be too high for conduction-only: + qSurf_Wm2 = (Planet.T_K[1] - Planet.T_K[0]) / (Planet.r_m[0] - Planet.r_m[1]) * Planet.kTherm_WmK[0] + Planet.Ocean.QfromMantle_W = qSurf_Wm2 * 4*np.pi * Planet.Bulk.R_m**2 + + # We leave the remaining quantities as initially assigned, + # as we find the initial profile assuming conduction only. + else: + # Now model conductive + convective layers + # Get layer transition indices from previous profile + # Find index where depth exceeds conductive lid thickness (eLid_m) + try: + # nConduct is the first index where depth (z_m) is greater than the conductive lid thickness + nConduct = next(i[0] for i,val in np.ndenumerate(Planet.z_m) if val > Planet.eLid_m) + except StopIteration: + # If no depths are found, the resolution is too low + raise RuntimeError('Failed to find any depth indices for upper TBL of ice III. Try increasing Steps.nIceVLitho.') + + # Find number of points in convective layer by getting index where depth is greater than + # bottom of ice shell minus thermal boundary layer thickness, and subtracting conductive index + try: + nConvect = next(i[0] for i,val in np.ndenumerate(Planet.z_m) if val > zbI_m - Planet.deltaTBL_m) - nConduct + except StopIteration: + raise RuntimeError('Failed to find any depth indices for lower TBL of ice III. Try increasing Steps.nIceVLitho.') + + # Get indices for thermal boundary layer - from end of convective layer to bottom of ice shell + indsTBL = range(nConduct + nConvect, Planet.Steps.nIbottom+1) + + + # Get pressure at top of convecting layer + PconvTop_MPa = Planet.P_MPa[nConduct] + + # Reset profile of upper layers, keeping pressure values fixed + if Planet.Do.CLATHRATE: + log.debug('Evaluating clathrate layers in conductive lid.') + + if Planet.Bulk.clathType == 'top': + if (Planet.eLid_m < Planet.zClath_m): + Planet.Bulk.clathMaxDepth_m = Planet.eLid_m + log.debug('Clathrate lid thickness was greater than the conductive lid thickness. ' + + 'Planet.Bulk.clathMaxDepth_m has been reduced to be equal to the conductive lid thickness.') + if Planet.PbClathMax_MPa > PconvTop_MPa: + Planet.PbClathMax_MPa = PconvTop_MPa + Planet.TclathTrans_K = Planet.Tconv_K + Planet.P_MPa[:Planet.Steps.nClath] = np.linspace(Planet.P_MPa[0], PconvTop_MPa, Planet.Steps.nClath+1)[:-1] + Planet.P_MPa[Planet.Steps.nClath:Planet.Steps.nIbottom+1] = \ + np.linspace(PconvTop_MPa, Planet.PbI_MPa, Planet.Steps.nIceI+1) + # Reassign T profile to be consistent with conduction + PlidRatios = Planet.P_MPa[:Planet.Steps.nClath+1] / PconvTop_MPa + Planet.T_K[:Planet.Steps.nClath+1] = Planet.Tconv_K**(PlidRatios) * Planet.T_K[0]**(1 - PlidRatios) + + # Reset nConduct/nConvect/indsTBL to account for the index shift moving clathrates to be above the + # transition to ice I + nConduct = Planet.Steps.nClath + nConvect = next(i[0] for i,val in np.ndenumerate(Planet.z_m) if val > zbI_m - Planet.deltaTBL_m) - nConduct + indsTBL = range(nConduct + nConvect, Planet.Steps.nIbottom+1) + + else: + log.warning('Max. clathrate layer thickness is less than that of the stagnant lid. Lid thickness ' + + 'was calculated using a constant thermal conductivity equal to that of clathrates at the ' + + 'surface, so the properties of the ice I conductive layer between the clathrate lid and ' + + 'convective region are likely to be physically inconsistent (i.e., an unrealistically low ' + + 'thermal conductivity). Increase Bulk.clathMaxDepth_m to greater than ' + + f'{Planet.eLid_m/1e3:.3f} km for these run settings to avoid this problem.') + # Keep existing transition (P,T) from clathrates to ice I + Planet.P_MPa[:Planet.Steps.nClath] = np.linspace(Planet.P_MPa[0], Planet.PbClathMax_MPa, Planet.Steps.nClath+1)[:-1] + Planet.P_MPa[Planet.Steps.nClath:Planet.Steps.nIbottom+1] = \ + np.linspace(Planet.PbClathMax_MPa, Planet.PbI_MPa, Planet.Steps.nIceI+1) + # Model conduction in ice I between clathrate lid and convective region + PlidRatiosClath = (Planet.P_MPa[:Planet.Steps.nClath+1] - Planet.P_MPa[0]) / (Planet.PbClathMax_MPa - Planet.P_MPa[0]) + Planet.T_K[:Planet.Steps.nClath+1] = Planet.TclathTrans_K**(PlidRatiosClath) * Planet.T_K[0]**(1 - PlidRatiosClath) + PlidRatiosIceI = (Planet.P_MPa[Planet.Steps.nClath:nConduct+1] - Planet.PbClathMax_MPa) / (PconvTop_MPa - Planet.PbClathMax_MPa) + Planet.T_K[Planet.Steps.nClath:nConduct+1] = Planet.Tconv_K**(PlidRatiosIceI) * Planet.TclathTrans_K**(1 - PlidRatiosIceI) + if Planet.Do.MIXED_CLATHRATE_ICE: + phaseStr = 'MixedClathrateIh' + else: + phaseStr = 'Clath' + # Get physical properties of clathrate lid + Planet = EvalLayerProperties(Planet, Params, 0, Planet.Steps.nClath, Planet.Ocean.surfIceEOS[phaseStr], + Planet.P_MPa[:Planet.Steps.nClath], Planet.T_K[:Planet.Steps.nClath]) + + Planet.rho_kgm3[:Planet.Steps.nClath] = Planet.rhoMatrix_kgm3[:Planet.Steps.nClath] + 0.0 - # Reset nConduct/nConvect/indsTBL to account for the index shift moving clathrates to be above the - # transition to ice I - nConduct = Planet.Steps.nClath - nConvect = next(i[0] for i,val in np.ndenumerate(Planet.z_m) if val > zbI_m - Planet.deltaTBL_m) - nConduct - indsTBL = range(nConduct + nConvect, Planet.Steps.nIbottom+1) - else: - log.warning('Max. clathrate layer thickness is less than that of the stagnant lid. Lid thickness ' + - 'was calculated using a constant thermal conductivity equal to that of clathrates at the ' + - 'surface, so the properties of the ice I conductive layer between the clathrate lid and ' + - 'convective region are likely to be physically inconsistent (i.e., an unrealistically low ' + - 'thermal conductivity). Increase Bulk.clathMaxDepth_m to greater than ' + - f'{Planet.eLid_m/1e3:.3f} km for these run settings to avoid this problem.') - # Keep existing transition (P,T) from clathrates to ice I - Planet.P_MPa[:Planet.Steps.nClath] = np.linspace(Planet.P_MPa[0], Planet.PbClathMax_MPa, Planet.Steps.nClath+1)[:-1] - Planet.P_MPa[Planet.Steps.nClath:Planet.Steps.nIbottom+1] = \ - np.linspace(Planet.PbClathMax_MPa, Planet.PbI_MPa, Planet.Steps.nIceI+1) - # Model conduction in ice I between clathrate lid and convective region - PlidRatiosClath = (Planet.P_MPa[:Planet.Steps.nClath+1] - Planet.P_MPa[0]) / (Planet.PbClathMax_MPa - Planet.P_MPa[0]) - Planet.T_K[:Planet.Steps.nClath+1] = Planet.TclathTrans_K**(PlidRatiosClath) * Planet.T_K[0]**(1 - PlidRatiosClath) - PlidRatiosIceI = (Planet.P_MPa[Planet.Steps.nClath:nConduct+1] - Planet.PbClathMax_MPa) / (PconvTop_MPa - Planet.PbClathMax_MPa) - Planet.T_K[Planet.Steps.nClath:nConduct+1] = Planet.Tconv_K**(PlidRatiosIceI) * Planet.TclathTrans_K**(1 - PlidRatiosIceI) - if Planet.Do.MIXED_CLATHRATE_ICE: - phaseStr = 'MixedClathrateIh' else: - phaseStr = 'Clath' - # Get physical properties of clathrate lid - Planet = EvalLayerProperties(Planet, Params, 0, Planet.Steps.nClath, Planet.Ocean.surfIceEOS[phaseStr], - Planet.P_MPa[:Planet.Steps.nClath], Planet.T_K[:Planet.Steps.nClath]) - - Planet.rho_kgm3[:Planet.Steps.nClath] = Planet.rhoMatrix_kgm3[:Planet.Steps.nClath] + 0.0 - + raise ValueError(f'IceIConvect behavior is not defined for Bulk.clathType "{Planet.Bulk.clathType}".') else: - raise ValueError(f'IceIConvect behavior is not defined for Bulk.clathType "{Planet.Bulk.clathType}".') - else: - log.debug('Modeling ice I conduction in stagnant lid...') - # Reassign conductive profile with new bottom temperature for conductive layer - PlidRatios = (Planet.P_MPa[:nConduct+1] - Planet.P_MPa[0]) / (PconvTop_MPa - Planet.P_MPa[0]) - Planet.T_K[:nConduct+1] = Planet.Tconv_K**(PlidRatios) * Planet.T_K[0]**(1 - PlidRatios) - - # Get physical properties of upper conducting layer, and include 1 layer of convective layer for next step - Planet = EvalLayerProperties(Planet, Params, Planet.Steps.nClath, nConduct+1, Planet.Ocean.surfIceEOS['Ih'], - Planet.P_MPa[Planet.Steps.nClath:nConduct+1], Planet.T_K[Planet.Steps.nClath:nConduct+1]) - Planet.rho_kgm3[Planet.Steps.nClath:nConduct+1] = Planet.rhoMatrix_kgm3[Planet.Steps.nClath:nConduct+1] + 0.0 - - Planet = PropagateConduction(Planet, Params, 0, nConduct-1) - log.debug('Stagnant lid conductive profile complete. Modeling ice I convecting layer...') - - Planet = PropagateAdiabaticSolid(Planet, Params, nConduct, nConduct+nConvect, Planet.Ocean.surfIceEOS['Ih']) - # Set logical array for convective ice layers - Planet.Steps.iConv[nConduct:nConduct+nConvect] = True - log.debug('Convective profile complete. Modeling conduction in lower thermal boundary layer...') - - # Reassign conductive profile with new top temperature for conductive layer - PTBLratios = (Planet.P_MPa[indsTBL] - Planet.P_MPa[nConduct+nConvect-1]) / (Planet.PbI_MPa - Planet.P_MPa[nConduct+nConvect-1]) - Planet.T_K[indsTBL] = Planet.Bulk.Tb_K**(PTBLratios) * Planet.T_K[nConduct+nConvect-1]**(1 - PTBLratios) - - # Get physical properties of thermal boundary layer - Planet = EvalLayerProperties(Planet, Params, indsTBL[0], indsTBL[-1]+1, - Planet.Ocean.surfIceEOS['Ih'], - Planet.P_MPa[indsTBL], Planet.T_K[indsTBL]) - Planet.rho_kgm3[indsTBL] = Planet.rhoMatrix_kgm3[indsTBL] + 0.0 - - # Apply conductive profile to lower TBL - Planet = PropagateConduction(Planet, Params, indsTBL[0]-1, indsTBL[-1]) - - log.debug('Ice I convection calculations complete.') + log.debug('Modeling ice I conduction in stagnant lid...') + # Reassign conductive profile with new bottom temperature for conductive layer + PlidRatios = (Planet.P_MPa[:nConduct+1] - Planet.P_MPa[0]) / (PconvTop_MPa - Planet.P_MPa[0]) + Planet.T_K[:nConduct+1] = Planet.Tconv_K**(PlidRatios) * Planet.T_K[0]**(1 - PlidRatios) + + # Get physical properties of upper conducting layer, and include 1 layer of convective layer for next step + Planet = EvalLayerProperties(Planet, Params, Planet.Steps.nClath, nConduct+1, Planet.Ocean.surfIceEOS['Ih'], + Planet.P_MPa[Planet.Steps.nClath:nConduct+1], Planet.T_K[Planet.Steps.nClath:nConduct+1]) + Planet.rho_kgm3[Planet.Steps.nClath:nConduct+1] = Planet.rhoMatrix_kgm3[Planet.Steps.nClath:nConduct+1] + 0.0 + + Planet = PropagateConduction(Planet, Params, 0, nConduct-1) + log.debug('Stagnant lid conductive profile complete. Modeling ice I convecting layer...') + + Planet = PropagateAdiabaticSolid(Planet, Params, nConduct, nConduct+nConvect, Planet.Ocean.surfIceEOS['Ih']) + # Set logical array for convective ice layers + Planet.Steps.iConv[nConduct:nConduct+nConvect] = True + log.debug('Convective profile complete. Modeling conduction in lower thermal boundary layer...') + + # Reassign conductive profile with new top temperature for conductive layer + PTBLratios = (Planet.P_MPa[indsTBL] - Planet.P_MPa[nConduct+nConvect-1]) / (Planet.PbI_MPa - Planet.P_MPa[nConduct+nConvect-1]) + Planet.T_K[indsTBL] = Planet.Bulk.Tb_K**(PTBLratios) * Planet.T_K[nConduct+nConvect-1]**(1 - PTBLratios) + + # Get physical properties of thermal boundary layer + Planet = EvalLayerProperties(Planet, Params, indsTBL[0], indsTBL[-1]+1, + Planet.Ocean.surfIceEOS['Ih'], + Planet.P_MPa[indsTBL], Planet.T_K[indsTBL]) + Planet.rho_kgm3[indsTBL] = Planet.rhoMatrix_kgm3[indsTBL] + 0.0 + + # Apply conductive profile to lower TBL + Planet = PropagateConduction(Planet, Params, indsTBL[0]-1, indsTBL[-1]) + + log.debug('Ice I convection calculations complete.') return Planet @@ -190,15 +294,17 @@ def IceIConvectPorous(Planet, Params): Planet.RaConvect, Planet.RaCrit = \ ConvectionDeschampsSotin2001(Planet.T_K[0], Planet.r_m[0], Planet.kTherm_WmK[0], Planet.Bulk.Tb_K, zbI_m, Planet.g_ms2[0], Pmid_MPa, Planet.Ocean.EOS, - Planet.Ocean.surfIceEOS['Ih'], 1, Planet.Do.EQUIL_Q) - + Planet.Ocean.surfIceEOS['Ih'], 1, Planet.Do.EQUIL_Q, Planet.Ocean.Eact_kJmol) + # Update the viscosity of the Ice Ih EOS to use the convective viscosity + Planet.Ocean.surfIceEOS['Ih'].updateConvectionViscosity(Planet.etaConv_Pas, Planet.Tconv_K) log.debug(f'Ice I convection parameters:\n T_convect = {Planet.Tconv_K:.3f} K,\n' + f' Viscosity etaConvect = {Planet.etaConv_Pas:.3e} Pa*s,\n' + f' Conductive lid thickness eLid = {Planet.eLid_m/1e3:.1f} km,\n' + f' Convecting layer thickness Dconv = {Planet.Dconv_m/1e3:.1f} km,\n' + f' Lower TBL thickness deltaTBL = {Planet.deltaTBL_m/1e3:.1f} km,\n' + f' Rayleigh number Ra = {Planet.RaConvect:.3e}.') - + # Update the viscosity of the Ice Ih EOS to use the convective viscosity + Planet.Ocean.surfIceEOS['Ih'].updateConvectionViscosity(Planet.etaConv_Pas, Planet.Tconv_K) # Check for whole-lid conduction if(zbI_m <= Planet.eLid_m + Planet.deltaTBL_m): log.info(f'Ice shell thickness ({zbI_m/1e3:.1f} km) is less than that of the thermal ' + @@ -353,7 +459,7 @@ def IceIIIConvectSolid(Planet, Params): Planet.RaConvectIII, Planet.RaCritIII = ConvectionDeschampsSotin2001(Planet.Bulk.Tb_K, Planet.r_m[Planet.Steps.nIbottom], Planet.kTherm_WmK[Planet.Steps.nIbottom], Planet.Bulk.TbIII_K, zbIII_m, Planet.g_ms2[Planet.Steps.nIbottom], PmidIII_MPa, Planet.Ocean.EOS, - Planet.Ocean.surfIceEOS['III'], 3, Planet.Do.EQUIL_Q) + Planet.Ocean.surfIceEOS['III'], 3, Planet.Do.EQUIL_Q, Planet.Ocean.Eact_kJmol) log.debug(f'Ice III convection parameters:\n T_convectIII = {Planet.TconvIII_K:.3f} K,\n' + f' Viscosity etaConvectIII = {Planet.etaConvIII_Pas:.3e} Pa*s,\n' + @@ -361,7 +467,8 @@ def IceIIIConvectSolid(Planet, Params): f' Convecting layer thickness DconvIII = {Planet.DconvIII_m/1e3:.1f} km,\n' + f' Lower TBL thickness deltaTBLIII = {Planet.deltaTBLIII_m/1e3:.1f} km,\n' + f' Rayleigh number RaIII = {Planet.RaConvectIII:.3e}.') - + # Update the viscosity of the Ice III EOS to use the convective viscosity + Planet.Ocean.surfIceEOS['III'].updateConvectionViscosity(Planet.etaConvIII_Pas, Planet.TconvIII_K) # Check for whole-lid conduction if(zbIII_m <= Planet.eLidIII_m + Planet.deltaTBLIII_m): log.info(f'Underplate ice III thickness ({zbIII_m/1e3:.1f} km) is less than that of the thermal ' + @@ -450,7 +557,7 @@ def IceIIIConvectPorous(Planet, Params): Planet.RaConvectIII, Planet.RaCritIII = ConvectionDeschampsSotin2001(Planet.Bulk.Tb_K, Planet.r_m[Planet.Steps.nIbottom], Planet.kTherm_WmK[Planet.Steps.nIbottom], Planet.Bulk.TbIII_K, zbIII_m, Planet.g_ms2[Planet.Steps.nIbottom], PmidIII_MPa, Planet.Ocean.EOS, - Planet.Ocean.surfIceEOS['III'], 3, Planet.Do.EQUIL_Q) + Planet.Ocean.surfIceEOS['III'], 3, Planet.Do.EQUIL_Q, Planet.Ocean.Eact_kJmol) log.debug(f'Ice III convection parameters:\n T_convectIII = {Planet.TconvIII_K:.3f} K,\n' + f' Viscosity etaConvectIII = {Planet.etaConvIII_Pas:.3e} Pa*s,\n' + @@ -458,7 +565,8 @@ def IceIIIConvectPorous(Planet, Params): f' Convecting layer thickness DconvIII = {Planet.DconvIII_m/1e3:.1f} km,\n' + f' Lower TBL thickness deltaTBLIII = {Planet.deltaTBLIII_m/1e3:.1f} km,\n' + f' Rayleigh number RaIII = {Planet.RaConvectIII:.3e}.') - + # Update the viscosity of the Ice III EOS to use the convective viscosity + Planet.Ocean.surfIceEOS['III'].updateConvectionViscosity(Planet.etaConvIII_Pas, Planet.TconvIII_K) # Check for whole-lid conduction if(zbIII_m <= Planet.eLidIII_m + Planet.deltaTBLIII_m): log.info(f'Underplate ice III thickness ({zbIII_m/1e3:.1f} km) is less than that of the thermal ' + @@ -554,7 +662,7 @@ def IceVConvectSolid(Planet, Params): Planet.RaConvectV, Planet.RaCritV = ConvectionDeschampsSotin2001(Planet.Bulk.TbIII_K, Planet.r_m[Planet.Steps.nIIIbottom], Planet.kTherm_WmK[Planet.Steps.nIIIbottom], Planet.Bulk.TbV_K, zbV_m, Planet.g_ms2[Planet.Steps.nIIIbottom], PmidV_MPa, Planet.Ocean.EOS, - Planet.Ocean.surfIceEOS['V'], 5, Planet.Do.EQUIL_Q) + Planet.Ocean.surfIceEOS['V'], 5, Planet.Do.EQUIL_Q, Planet.Ocean.Eact_kJmol) log.debug(f'Ice V convection parameters:\n T_convectV = {Planet.TconvV_K:.3f} K,\n' + f' Viscosity etaConvectV = {Planet.etaConvV_Pas:.3e} Pa*s,\n' + @@ -562,7 +670,8 @@ def IceVConvectSolid(Planet, Params): f' Convecting layer thickness DconvV = {Planet.DconvV_m/1e3:.1f} km,\n' + f' Lower TBL thickness deltaTBLV = {Planet.deltaTBLV_m/1e3:.1f} km,\n' + f' Rayleigh number RaV = {Planet.RaConvectV:.3e}.') - + # Update the viscosity of the Ice V EOS to use the convective viscosity + Planet.Ocean.surfIceEOS['V'].updateConvectionViscosity(Planet.etaConvV_Pas, Planet.TconvV_K) # Check for whole-lid conduction if(zbV_m <= Planet.eLidV_m + Planet.deltaTBLV_m): log.info(f'Underplate ice V thickness ({zbV_m/1e3:.1f} km) is less than that of the thermal ' + @@ -653,7 +762,7 @@ def IceVConvectPorous(Planet, Params): Planet.RaConvectV, Planet.RaCritV = ConvectionDeschampsSotin2001(Planet.Bulk.TbIII_K, Planet.r_m[Planet.Steps.nIIIbottom], Planet.kTherm_WmK[Planet.Steps.nIIIbottom], Planet.Bulk.TbV_K, zbV_m, Planet.g_ms2[Planet.Steps.nIIIbottom], PmidV_MPa, Planet.Ocean.EOS, - Planet.Ocean.surfIceEOS['V'], 5, Planet.Do.EQUIL_Q) + Planet.Ocean.surfIceEOS['V'], 5, Planet.Do.EQUIL_Q, Planet.Ocean.Eact_kJmol) log.debug(f'Ice V convection parameters:\n T_convectV = {Planet.TconvV_K:.3f} K,\n' + f' Viscosity etaConvectV = {Planet.etaConvV_Pas:.3e} Pa*s,\n' + @@ -661,7 +770,8 @@ def IceVConvectPorous(Planet, Params): f' Convecting layer thickness DconvV = {Planet.DconvV_m/1e3:.1f} km,\n' + f' Lower TBL thickness deltaTBLV = {Planet.deltaTBLV_m/1e3:.1f} km,\n' + f' Rayleigh number RaV = {Planet.RaConvectV:.3e}.') - + # Update the viscosity of the Ice V EOS to use the convective viscosity + Planet.Ocean.surfIceEOS['V'].updateConvectionViscosity(Planet.etaConvV_Pas, Planet.TconvV_K) # Check for whole-lid conduction if(zbV_m <= Planet.eLidV_m + Planet.deltaTBLV_m): log.info(f'Underplate ice V thickness ({zbV_m/1e3:.1f} km) is less than that of the thermal ' + @@ -768,7 +878,7 @@ def ClathShellConvectSolid(Planet, Params): Planet.RaConvect, Planet.RaCrit = \ ConvectionDeschampsSotin2001(Planet.T_K[0], Planet.r_m[0], Planet.kTherm_WmK[0], Planet.Bulk.Tb_K, zbI_m, Planet.g_ms2[0], Pmid_MPa, Planet.Ocean.surfIceEOS[phaseStr], - Planet.Ocean.surfIceEOS[phaseStr], phaseIndex, Planet.Do.EQUIL_Q) + Planet.Ocean.surfIceEOS[phaseStr], phaseIndex, Planet.Do.EQUIL_Q, Planet.Ocean.Eact_kJmol) log.debug(f'Clathrate shell convection parameters:\n T_convect = {Planet.Tconv_K:.3f} K,\n' + f' Viscosity etaConvect = {Planet.etaConv_Pas:.3e} Pa*s,\n' + @@ -776,7 +886,8 @@ def ClathShellConvectSolid(Planet, Params): f' Convecting layer thickness Dconv = {Planet.Dconv_m/1e3:.1f} km,\n' + f' Lower TBL thickness deltaTBL = {Planet.deltaTBL_m/1e3:.1f} km,\n' + f' Rayleigh number Ra = {Planet.RaConvect:.3e}.') - + # Update the viscosity of the Ice Ih EOS to use the convective viscosity + Planet.Ocean.surfIceEOS[phaseStr].updateConvectionViscosity(Planet.etaConv_Pas, Planet.Tconv_K) # Check for whole-lid conduction if(zbI_m <= Planet.eLid_m + Planet.deltaTBL_m): log.info(f'Ice shell thickness ({zbI_m/1e3:.1f} km) is less than that of the thermal ' + @@ -862,7 +973,7 @@ def ClathShellConvectPorous(Planet, Params): Planet.RaConvect, Planet.RaCrit = \ ConvectionDeschampsSotin2001(Planet.T_K[0], Planet.r_m[0], Planet.kTherm_WmK[0], Planet.Bulk.Tb_K, zbI_m, Planet.g_ms2[0], Pmid_MPa, Planet.Ocean.surfIceEOS[phaseStr], - Planet.Ocean.surfIceEOS[phaseStr], Constants.phaseClath, Planet.Do.EQUIL_Q) + Planet.Ocean.surfIceEOS[phaseStr], Constants.phaseClath, Planet.Do.EQUIL_Q, Planet.Ocean.Eact_kJmol) log.debug(f'Clathrate shell convection parameters:\n T_convect = {Planet.Tconv_K:.3f} K,\n' + f' Viscosity etaConvect = {Planet.etaConv_Pas:.3e} Pa*s,\n' + @@ -870,7 +981,8 @@ def ClathShellConvectPorous(Planet, Params): f' Convecting layer thickness Dconv = {Planet.Dconv_m/1e3:.1f} km,\n' + f' Lower TBL thickness deltaTBL = {Planet.deltaTBL_m/1e3:.1f} km,\n' + f' Rayleigh number Ra = {Planet.RaConvect:.3e}.') - + # Update the viscosity of the Ice Ih EOS to use the convective viscosity + Planet.Ocean.surfIceEOS[phaseStr].updateConvectionViscosity(Planet.etaConv_Pas, Planet.Tconv_K) # Check for whole-lid conduction if(zbI_m <= Planet.eLid_m + Planet.deltaTBL_m): log.info(f'Ice shell thickness ({zbI_m/1e3:.1f} km) is less than that of the thermal ' + diff --git a/PlanetProfile/Thermodynamics/ThermalProfiles/IceConduction.py b/PlanetProfile/Thermodynamics/ThermalProfiles/IceConduction.py index cfb38001..285f3d38 100644 --- a/PlanetProfile/Thermodynamics/ThermalProfiles/IceConduction.py +++ b/PlanetProfile/Thermodynamics/ThermalProfiles/IceConduction.py @@ -2,10 +2,10 @@ import logging import scipy.interpolate as spi from PlanetProfile.Thermodynamics.Geophysical import PropagateConduction, EvalLayerProperties, \ - PorosityCorrectionVacIce, PorosityCorrectionFilledIce -from PlanetProfile.Thermodynamics.HydroEOS import GetIceEOS + PorosityCorrectionVacIce, PorosityCorrectionFilledIce, PropogateConductionFromDepth +from PlanetProfile.Thermodynamics.HydroEOS import GetIceEOS, GetOceanEOS from PlanetProfile.Utilities.Indexing import PhaseConv -from PlanetProfile.Thermodynamics.ThermalProfiles.ThermalProfiles import GetPbConduct +from PlanetProfile.Thermodynamics.ThermalProfiles.ThermalProfiles import GetPbConduct, GetTfreeze from PlanetProfile.Utilities.defineStructs import Constants # Assign logger @@ -21,27 +21,59 @@ def IceIWholeConductSolid(Planet, Params): All physical layer arrays """ icePhase = PhaseConv(Planet.phase[0]) - - # Set linear P and adiabatic T in ice I layers. Include 1 extra for P and T to assign next phase to the values - # at the phase transition - PIceI_MPa = np.linspace(Planet.P_MPa[0], Planet.PbI_MPa, Planet.Steps.nIbottom+1) - Pratios = (PIceI_MPa - Planet.P_MPa[0]) / (Planet.PbI_MPa - Planet.P_MPa[0]) - TIceI_K = Planet.Bulk.Tb_K**(Pratios) * Planet.T_K[0]**(1 - Pratios) - Planet.P_MPa[:Planet.Steps.nIbottom+1] = PIceI_MPa - Planet.T_K[:Planet.Steps.nIbottom+1] = TIceI_K + if Planet.Do.NON_SELF_CONSISTENT: + zIceI_m = np.linspace(Planet.z_m[0], Planet.dzIceI_km * 1e3, Planet.Steps.nIbottom+1) + Planet.z_m[:Planet.Steps.nIbottom+1] = zIceI_m + # Set linear P and adiabatic T in ice I layers. Include 1 extra for P and T to assign next phase to the values + # at the phase transition + PIceI_MPa = np.arange(Planet.P_MPa[0], Planet.PfreezeUpper_MPa, Planet.Ocean.deltaP) + TIceI_K = np.arange(Planet.Bulk.Tsurf_K, Planet.Bulk.TfreezeUpper_K, Planet.Ocean.deltaT) + Planet.Ocean.surfIceEOS[icePhase] = GetIceEOS(PIceI_MPa, TIceI_K, icePhase, EXTRAP=Params.EXTRAP_ICE[icePhase], + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, kThermConst_WmK=Planet.Ocean.kThermIce_WmK, + mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}, + doConstantProps=Planet.Do.CONSTANTPROPSEOS, + constantProperties=Planet.Ocean.constantProperties[icePhase], + minPres_MPa=Params.minPres_MPa, minTres_K=Params.minTres_K) + # Getfreezing temperature + # Calculate the bottom ice temperature, otherwise use the bulk temperature + if Planet.Ocean.comp == 'none': + Planet.Bulk.Tb_K = Constants.T0 + else: + # Query bulk temperature and EC from ocean composition + Pbottom_MPa = Planet.dzIceI_km * 1e3 * Planet.g_ms2[0] * Planet.Ocean.surfIceEOS[icePhase].fn_rho_kgm3(Planet.Bulk.Psurf_MPa, Planet.Bulk.Tsurf_K) + Pmelt_MPa = np.linspace(Pbottom_MPa - 0.01, Planet.Bulk.Psurf_MPa + 0.01, 3) + Tmelt_K = np.linspace(Planet.TfreezeLower_MPa, Planet.TfreezeUpper_MPa, Planet.TfreezeRes_K) + Planet.Ocean.meltEOS = GetOceanEOS(Planet.Ocean.comp, Planet.Ocean.wOcean_ppt, Pmelt_MPa, Tmelt_K, + propsStepReductionFactor=Planet.Ocean.propsStepReductionFactor) + Planet.Bulk.Tb_K = GetTfreeze(Planet.Ocean.meltEOS, Planet.Pb_MPa, Planet.TfreezeLower_K, TRes_K=Planet.TfreezeRes_K) + + # Calculate remaining physical properites of upper ice I from depth + Planet = PropogateConductionFromDepth(Planet, Params, 0, Planet.Steps.nIbottom, Planet.Bulk.Tb_K, Planet.Ocean.surfIceEOS[icePhase]) + Planet.PbI_MPa = Planet.P_MPa[Planet.Steps.nIbottom] + PIceI_MPa = np.linspace(Planet.P_MPa[0], Planet.PbI_MPa, Planet.Steps.nIbottom+1) + else: + # Set linear P and adiabatic T in ice I layers. Include 1 extra for P and T to assign next phase to the values + # at the phase transition + PIceI_MPa = np.linspace(Planet.P_MPa[0], Planet.PbI_MPa, Planet.Steps.nIbottom+1) + Pratios = (PIceI_MPa - Planet.P_MPa[0]) / (Planet.PbI_MPa - Planet.P_MPa[0]) + TIceI_K = Planet.Bulk.Tb_K**(Pratios) * Planet.T_K[0]**(1 - Pratios) + Planet.P_MPa[:Planet.Steps.nIbottom+1] = PIceI_MPa + Planet.T_K[:Planet.Steps.nIbottom+1] = TIceI_K - # Get ice EOS - Planet.Ocean.surfIceEOS[icePhase] = GetIceEOS(PIceI_MPa, TIceI_K, icePhase, EXTRAP=Params.EXTRAP_ICE[icePhase], - ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}) - - # Evaluate thermodynamic properties of uppermost ice - Planet = EvalLayerProperties(Planet, Params, 0, Planet.Steps.nIbottom, - Planet.Ocean.surfIceEOS[icePhase], PIceI_MPa[:-1], TIceI_K[:-1]) - # Fill additional arrays as needed for later compatibility with porosity calculations - Planet.rho_kgm3[:Planet.Steps.nIbottom] = Planet.rhoMatrix_kgm3[:Planet.Steps.nIbottom] + 0.0 + # Get ice EOS + Planet.Ocean.surfIceEOS[icePhase] = GetIceEOS(PIceI_MPa, TIceI_K, icePhase, EXTRAP=Params.EXTRAP_ICE[icePhase], + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, kThermConst_WmK=Planet.Ocean.kThermIce_WmK, + mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}, + minPres_MPa=Params.minPres_MPa, minTres_K=Params.minTres_K) + + # Evaluate thermodynamic properties of uppermost ice + Planet = EvalLayerProperties(Planet, Params, 0, Planet.Steps.nIbottom, + Planet.Ocean.surfIceEOS[icePhase], PIceI_MPa[:-1], TIceI_K[:-1]) + # Fill additional arrays as needed for later compatibility with porosity calculations + Planet.rho_kgm3[:Planet.Steps.nIbottom] = Planet.rhoMatrix_kgm3[:Planet.Steps.nIbottom] + 0.0 - # Calculate remaining physical properties of upper ice I - Planet = PropagateConduction(Planet, Params, 0, Planet.Steps.nIbottom) + # Calculate remaining physical properties of upper ice I + Planet = PropagateConduction(Planet, Params, 0, Planet.Steps.nIbottom) return Planet @@ -71,8 +103,9 @@ def IceIWholeConductPorous(Planet, Params): phiTop_frac=Planet.Ocean.phiMax_frac[icePhase], Pclosure_MPa=Planet.Ocean.Pclosure_MPa[icePhase], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE[icePhase], - ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, - mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}) + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, kThermConst_WmK=Planet.Ocean.kThermIce_WmK, + mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}, + minPres_MPa=Params.minPres_MPa, minTres_K=Params.minTres_K) # Evaluate thermodynamic properties of uppermost ice Planet = EvalLayerProperties(Planet, Params, 0, Planet.Steps.nIbottom, @@ -110,11 +143,13 @@ def IceIConductClathLidSolid(Planet, Params): # Get ice Ih EOS Planet.Ocean.surfIceEOS['Ih'] = GetIceEOS(Plin_MPa, Tlin_K, 'Ih', EXTRAP=Params.EXTRAP_ICE['Ih'], - ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT) + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, kThermConst_WmK=Planet.Ocean.kThermIce_WmK, + minPres_MPa=Params.minPres_MPa, minTres_K=Params.minTres_K) # Get clathrate EOS Planet.Ocean.surfIceEOS[phaseStr] = GetIceEOS(Plin_MPa, Tlin_K, phaseStr, EXTRAP=Params.EXTRAP_ICE[phaseStr], - ClathDissoc=Planet.Ocean.ClathDissoc, - mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}) + ClathDissoc=Planet.Ocean.ClathDissoc, kThermConst_WmK=Planet.Ocean.kThermIce_WmK, + mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}, + minPres_MPa=Params.minPres_MPa, minTres_K=Params.minTres_K) # Get approximate temperature of transition between clathrates and ice I zbApprox_m = (Planet.PbI_MPa - Planet.Bulk.Psurf_MPa) * 1e6 / Planet.g_ms2[0] / \ @@ -204,15 +239,17 @@ def IceIConductClathLidPorous(Planet, Params): phiTop_frac=Planet.Ocean.phiMax_frac['Ih'], Pclosure_MPa=Planet.Ocean.Pclosure_MPa['Ih'], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE['Ih'], - ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT) + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, kThermConst_WmK=Planet.Ocean.kThermIce_WmK, + minPres_MPa=Params.minPres_MPa, minTres_K=Params.minTres_K) # Get clathrate EOS Planet.Ocean.surfIceEOS[phaseStr] = GetIceEOS(Plin_MPa, Tlin_K, phaseStr, porosType=Planet.Ocean.porosType['Clath'], phiTop_frac=Planet.Ocean.phiMax_frac['Clath'], Pclosure_MPa=Planet.Ocean.Pclosure_MPa['Clath'], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE[phaseStr], - ClathDissoc=Planet.Ocean.ClathDissoc, - mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}) + ClathDissoc=Planet.Ocean.ClathDissoc, kThermConst_WmK=Planet.Ocean.kThermIce_WmK, + mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}, + minPres_MPa=Params.minPres_MPa, minTres_K=Params.minTres_K) # Get approximate temperature of transition between clathrates and ice I zbApprox_m = (Planet.PbI_MPa - Planet.Bulk.Psurf_MPa) * 1e6 / Planet.g_ms2[0] / \ @@ -303,8 +340,9 @@ def IceIConductClathUnderplateSolid(Planet, Params): PIceFull_MPa = np.linspace(Planet.P_MPa[0], Planet.PbI_MPa, Planet.Steps.nIbottom+1) TIceFull_K = np.linspace(Planet.T_K[0], Planet.Bulk.Tb_K, Planet.Steps.nIbottom) Planet.Ocean.surfIceEOS[phaseStr] = GetIceEOS(PIceFull_MPa, TIceFull_K, phaseStr, EXTRAP=Params.EXTRAP_ICE[phaseStr], - ClathDissoc=Planet.Ocean.ClathDissoc, - mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}) + ClathDissoc=Planet.Ocean.ClathDissoc, kThermConst_WmK=Planet.Ocean.kThermIce_WmK, + mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}, + minPres_MPa=Params.minPres_MPa, minTres_K=Params.minTres_K) rhoBot_kgm3 = Planet.Ocean.surfIceEOS[phaseStr].fn_rho_kgm3(Planet.PbI_MPa, Planet.Bulk.Tb_K) # Get approximate pressure change across clathrate layer (assuming surface gravity) @@ -325,7 +363,8 @@ def IceIConductClathUnderplateSolid(Planet, Params): # Get ice I EOS Planet.Ocean.surfIceEOS['Ih'] = GetIceEOS(PIceI_MPa, TIceFull_K, 'Ih', EXTRAP=Params.EXTRAP_ICE['Ih'], - ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT) + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, kThermConst_WmK=Planet.Ocean.kThermIce_WmK, + minPres_MPa=Params.minPres_MPa, minTres_K=Params.minTres_K) # Get approximate temperature at top of clathrate layer based on assumed surface heat flux # Need approx. depth first for curvature change to heat flux @@ -416,8 +455,9 @@ def IceIConductClathUnderplatePorous(Planet, Params): phiTop_frac=Planet.Ocean.phiMax_frac['Clath'], Pclosure_MPa=Planet.Ocean.Pclosure_MPa['Clath'], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE[phaseStr], - ClathDissoc=Planet.Ocean.ClathDissoc, - mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}) + ClathDissoc=Planet.Ocean.ClathDissoc, kThermConst_WmK=Planet.Ocean.kThermIce_WmK, + mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}, + minPres_MPa=Params.minPres_MPa, minTres_K=Params.minTres_K) rhoBot_kgm3 = Planet.Ocean.surfIceEOS[phaseStr].fn_rho_kgm3(Planet.PbI_MPa, Planet.Bulk.Tb_K) # Get approximate pressure change across clathrate layer (assuming surface gravity) @@ -438,7 +478,8 @@ def IceIConductClathUnderplatePorous(Planet, Params): phiTop_frac=Planet.Ocean.phiMax_frac['Ih'], Pclosure_MPa=Planet.Ocean.Pclosure_MPa['Ih'], phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE['Ih'], - ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT) + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, kThermConst_WmK=Planet.Ocean.kThermIce_WmK, + minPres_MPa=Params.minPres_MPa, minTres_K=Params.minTres_K) # Get approximate temperature at top of clathrate layer based on assumed surface heat flux # Need approx. depth first for curvature change to heat flux @@ -522,7 +563,8 @@ def IceIIIConductSolid(Planet, Params): Planet.T_K[Planet.Steps.nIbottom:Planet.Steps.nIIIbottom+1] = TIceIII_K # Get ice III EOS - Planet.Ocean.surfIceEOS['III'] = GetIceEOS(PIceIII_MPa, TIceIII_K, 'III', EXTRAP=Params.EXTRAP_ICE['III']) + Planet.Ocean.surfIceEOS['III'] = GetIceEOS(PIceIII_MPa, TIceIII_K, 'III', EXTRAP=Params.EXTRAP_ICE['III'], kThermConst_WmK=Planet.Ocean.kThermIce_WmK, + minPres_MPa=Params.minPres_MPa, minTres_K=Params.minTres_K) # Evaluate thermodynamic properties of upper ice III Planet = EvalLayerProperties(Planet, Params, Planet.Steps.nIbottom, Planet.Steps.nIIIbottom, @@ -552,7 +594,8 @@ def IceIIIConductPorous(Planet, Params): porosType=Planet.Ocean.porosType['III'], phiTop_frac=Planet.Ocean.phiMax_frac['III'], Pclosure_MPa=Planet.Ocean.Pclosure_MPa['III'], - phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE['III']) + phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE['III'], kThermConst_WmK=Planet.Ocean.kThermIce_WmK, + minPres_MPa=Params.minPres_MPa, minTres_K=Params.minTres_K) # Evaluate thermodynamic properties of upper ice III Planet = EvalLayerProperties(Planet, Params, Planet.Steps.nIbottom, Planet.Steps.nIIIbottom, @@ -581,7 +624,8 @@ def IceVConductSolid(Planet, Params): Planet.T_K[Planet.Steps.nIIIbottom:Planet.Steps.nSurfIce+1] = TIceV_K # Get ice V EOS - Planet.Ocean.surfIceEOS['V'] = GetIceEOS(PIceV_MPa, TIceV_K, 'V', EXTRAP=Params.EXTRAP_ICE['V']) + Planet.Ocean.surfIceEOS['V'] = GetIceEOS(PIceV_MPa, TIceV_K, 'V', EXTRAP=Params.EXTRAP_ICE['V'], kThermConst_WmK=Planet.Ocean.kThermIce_WmK, + minPres_MPa=Params.minPres_MPa, minTres_K=Params.minTres_K) # Evaluate thermodynamic properties of upper ice V Planet = EvalLayerProperties(Planet, Params, Planet.Steps.nIIIbottom, Planet.Steps.nSurfIce, @@ -611,7 +655,8 @@ def IceVConductPorous(Planet, Params): porosType=Planet.Ocean.porosType['V'], phiTop_frac=Planet.Ocean.phiMax_frac['V'], Pclosure_MPa=Planet.Ocean.Pclosure_MPa['V'], - phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE['V']) + phiMin_frac=Planet.Ocean.phiMin_frac, EXTRAP=Params.EXTRAP_ICE['V'], kThermConst_WmK=Planet.Ocean.kThermIce_WmK, + minPres_MPa=Params.minPres_MPa, minTres_K=Params.minTres_K) # Evaluate thermodynamic properties of upper ice V Planet = EvalLayerProperties(Planet, Params, Planet.Steps.nIIIbottom, Planet.Steps.nSurfIce, diff --git a/PlanetProfile/Thermodynamics/ThermalProfiles/ThermalProfiles.py b/PlanetProfile/Thermodynamics/ThermalProfiles/ThermalProfiles.py index ad4e34e2..3700d5bd 100644 --- a/PlanetProfile/Thermodynamics/ThermalProfiles/ThermalProfiles.py +++ b/PlanetProfile/Thermodynamics/ThermalProfiles/ThermalProfiles.py @@ -8,8 +8,64 @@ # Assign logger log = logging.getLogger('PlanetProfile') +def ConvectionPetricca2024(Tmelt_K, Ttop_K, zb_m, Eact_kJmol, etaMelt_Pas, rhoMid_kgm3, alphaMid_pK, KthermMid_WmK, CpMid_JkgK, gtop_ms2, phaseBot, rTop_m): + """ Thermodynamics calculations for convection in an ice layer + based on Petricca et al. (2024): https://doi.org/10.1016/j.icarus.2024.116120 + Petricca presents a model for convection in an ice layer based on the approximation + of convective temperature in the ice shell presented by Howell et al. (2021), approximation of + viscosity in the convective layer given by the Arrhenius equation, and + the assumption that the ice behavior is described by diffusion creep. + """ + # Calculate approximate convective temperature using Howell et al. (2021) + Tconv_K = (np.sqrt((4 * Tmelt_K * Constants.R / (Eact_kJmol * 1e3)) + 1) - 1) / (2 * Constants.R / (Eact_kJmol * 1e3)) + + + # Calculate viscosity by Arrhenius equation + etaConv_Pas = etaMelt_Pas * np.exp(Eact_kJmol * 1e3 * (Tmelt_K / Tconv_K - 1) / Constants.R / Tmelt_K) + + # Calculate thermal diffusivity + alphaMid_m2s = KthermMid_WmK / (rhoMid_kgm3 * CpMid_JkgK) + + # Calculate Rayleigh number + Ra = alphaMid_pK * rhoMid_kgm3 * gtop_ms2 * (Tmelt_K - Ttop_K) * zb_m**3 / etaConv_Pas / alphaMid_m2s + + # Calcualte critical Rayleigh number + RaCrit = GetRaCrit(Eact_kJmol, Tmelt_K, Ttop_K, Tconv_K) + + # Calculate curvature f, which is the ratio of the ice shell thickness to the radius of the planet + f = 1 - zb_m / rTop_m + # Calculate gamma, which describes the temperature dependence of viscosity + gamma = Eact_kJmol * 1e3 * (Tmelt_K - Ttop_K) / (Constants.R * Tconv_K**2) + # Calculate Nusselt number + Nu = (1.46 * Ra**(0.27)) / ((gamma ** 1.21)*(f ** 0.78)) + # Calculate deltaTv + deltaTv = (Tmelt_K - Ttop_K) / gamma + # Calculate heat flux throughout entire ice shell + qSurface_Wm2 = KthermMid_WmK * (Tmelt_K - Ttop_K) / (zb_m * f) + # Calculate heat flux through the convective region + qBot_Wm2 = 1.46 * Ra ** 0.27 / (f**1.78) * (deltaTv / (Tmelt_K - Ttop_K)) ** 1.21 * qSurface_Wm2 + + # Calculate thickness of conductive portion + eLid_m = KthermMid_WmK * (Tconv_K - Ttop_K) / qBot_Wm2 + + if eLid_m > zb_m: + log.warning('Conductive portion of ice shell is thicker than the ice shell. Only conduction will be modeled in this layer.') + eLid_m = zb_m + Tconv_K = Ttop_K + Dconv_m = 0 + else: + Dconv_m = zb_m - eLid_m + + # This method does not calculate the thickness of the lower thermalboundary layer, so we set it to 0 + deltaTBL_m = 0.0 + + Qbot_W = qBot_Wm2 * 4*np.pi * (rTop_m - zb_m)**2 + return Tconv_K, etaConv_Pas, eLid_m, Dconv_m, deltaTBL_m, Qbot_W, Ra, RaCrit + + + def ConvectionDeschampsSotin2001(Ttop_K, rTop_m, kTop_WmK, Tb_K, zb_m, gtop_ms2, Pmid_MPa, - oceanEOS, iceEOS, phaseBot, EQUIL_Q): + oceanEOS, iceEOS, phaseBot, EQUIL_Q, Eact_kJmol): """ Thermodynamics calculations for convection in an ice layer based on Deschamps and Sotin (2001): https://doi.org/10.1029/2000JE001253 Note that these authors solved for the scaling laws we apply in Cartesian @@ -52,13 +108,20 @@ def ConvectionDeschampsSotin2001(Ttop_K, rTop_m, kTop_WmK, Tb_K, zb_m, gtop_ms2, # Get phase of convecting region from passed iceEOS phaseMid = iceEOS.phaseID + phaseMidString = PhaseConv(phaseMid) # Numerical constants derived in Cartesian geometry from Deschamps and Sotin (2000) and used in # Deschamps and Sotin (2001) parameterization c1 = 1.43 c2 = -0.03 - # Numerical constants appearing in equations - A = Constants.Eact_kJmol[phaseMid] * 1e3 / Constants.R / Tb_K - B = Constants.Eact_kJmol[phaseMid] * 1e3 / 2 / Constants.R / c1 + if not np.isnan(Eact_kJmol[phaseMidString]): + # If we specify Eact_kJmol in Planet, then we should use those values, otherwise use constants + A = Eact_kJmol[phaseMidString] * 1e3 / Constants.R / Tb_K + B = Eact_kJmol[phaseMidString] * 1e3 / 2 / Constants.R / c1 + else: + # Numerical constants appearing in equations + A = Constants.Eact_kJmol[phaseMid] * 1e3 / Constants.R / Tb_K + B = Constants.Eact_kJmol[phaseMid] * 1e3 / 2 / Constants.R / c1 + C = c2 * (Tb_K - Ttop_K) # Temperature and viscosity of the "well-mixed" convective region Tconv_K = B * (np.sqrt(1 + 2/B*(Tb_K - C)) - 1) @@ -85,7 +148,7 @@ def ConvectionDeschampsSotin2001(Ttop_K, rTop_m, kTop_WmK, Tb_K, zb_m, gtop_ms2, log.warning(f'Pmid_MPa has been adjusted upward from {oldPmid_MPa} to {Pmid_MPa} to compensate.') # Get melting temperature for calculating viscosity relative to this temp - Pmelt_MPa = np.linspace(Pmid_MPa, Pmid_MPa+0.01, 6) + Pmelt_MPa = np.arange(Pmid_MPa - 0.05*3, Pmid_MPa+0.05*3, 0.05) if phaseMid >= Constants.phaseClath and phaseMid < Constants.phaseClath + 10: meltEOS = oceanEOS Tupper_K = Tb_K @@ -126,9 +189,13 @@ def ConvectionDeschampsSotin2001(Ttop_K, rTop_m, kTop_WmK, Tb_K, zb_m, gtop_ms2, # will be what determines the conductive thermal profile based on the heat flux through the lid. #eLid_m = kMid_WmK * (Tconv_K - Ttop_K) / qTop_Wm2 eLid_m = kTop_WmK * (Tconv_K - Ttop_K) / qTop_Wm2 - - # If the Rayleigh number is less than some critical value, convection does not occur. - RaCrit = GetRaCrit(Constants.Eact_kJmol[phaseBot], Tb_K, Ttop_K, Tconv_K) + phaseBotString = PhaseConv(phaseBot) + if not np.isnan(Eact_kJmol[phaseBotString]): + # Again, if we specify Eact_kJmol[phaseBot] in Planet, then we should use those values, otherwise use constants + RaCrit = GetRaCrit(Eact_kJmol[phaseBotString], Tb_K, Ttop_K, Tconv_K) + else: + # If the Rayleigh number is less than some critical value, convection does not occur. + RaCrit = GetRaCrit(Constants.Eact_kJmol[phaseBot], Tb_K, Ttop_K, Tconv_K) if(Ra < RaCrit): log.debug(f'Rayleigh number of {Ra:.3e} in the surface ice {PhaseConv(phaseBot)} ' + f'layer is less than the critical value of {RaCrit:.3e}. ' + diff --git a/PlanetProfile/Thermodynamics/Viscosity.py b/PlanetProfile/Thermodynamics/Viscosity.py index 8920aabf..b14dc2a8 100644 --- a/PlanetProfile/Thermodynamics/Viscosity.py +++ b/PlanetProfile/Thermodynamics/Viscosity.py @@ -2,18 +2,20 @@ import logging import scipy.interpolate as spi from PlanetProfile.Thermodynamics.HydroEOS import GetOceanEOS -from PlanetProfile.Utilities.defineStructs import EOSlist +from PlanetProfile.Utilities.defineStructs import EOSlist, Timing +import time from PlanetProfile.Utilities.Indexing import GetPhaseIndices # Assign logger log = logging.getLogger('PlanetProfile') def ViscosityCalcs(Planet, Params): + Timing.setFunctionTime(time.time()) # Initialize outputs as NaN so that we get errors if we missed any layers Planet.eta_Pas = np.zeros(Planet.Steps.nTotal) * np.nan # Only perform calculations if this is a valid profile - if Planet.Do.VALID: + if Planet.Do.VALID or (Params.ALLOW_BROKEN_MODELS and Planet.Do.STILL_CALCULATE_BROKEN_PROPERTIES): # Identify which indices correspond to which phases indsLiq, indsI, indsIwet, indsII, indsIIund, indsIII, indsIIIund, indsV, indsVund, indsVI, indsVIund, \ indsClath, indsClathWet, indsMixedClathrateIh, indsMixedClathrateII, indsMixedClathrateIII, indsMixedClathrateV, indsMixedClathrateVI, \ @@ -23,7 +25,7 @@ def ViscosityCalcs(Planet, Params): if Params.CALC_VISCOSITY: # Make sure the necessary EOSs have been loaded (mainly only important in parallel ExploreOgram runs) - if not Planet.Do.NO_H2O and Planet.Ocean.EOS.key not in EOSlist.loaded.keys(): + if not (Planet.Do.NO_H2O or Planet.Do.NO_OCEAN) and Planet.Ocean.EOS.key not in EOSlist.loaded.keys(): POcean_MPa = np.arange(Planet.PfreezeLower_MPa, Planet.Ocean.PHydroMax_MPa, Planet.Ocean.deltaP) TOcean_K = np.arange(Planet.Bulk.Tb_K, Planet.Ocean.THydroMax_K, @@ -37,7 +39,8 @@ def ViscosityCalcs(Planet, Params): phaseType=Planet.Ocean.phaseType, EXTRAP=Params.EXTRAP_OCEAN, sigmaFixed_Sm=Planet.Ocean.sigmaFixed_Sm, - etaFixed_Pas=None) # Causes ocean EOS to use default behavior for this comp + etaFixed_Pas=None, kThermConst_WmK=Planet.Ocean.kThermWater_WmK, + propsStepReductionFactor=Planet.Ocean.propsStepReductionFactor) if Planet.Do.POROUS_ICE: Planet = CalcViscPorIce(Planet, Params, indsLiq, indsI, indsIwet, indsII, indsIIund, @@ -60,6 +63,7 @@ def ViscosityCalcs(Planet, Params): if Planet.Do.Fe_CORE: Planet = CalcViscCore(Planet, Params, indsFe) + Timing.printFunctionTimeDifference('ViscosityCalcs()', time.time()) return Planet diff --git a/PlanetProfile/Utilities/DataManip.py b/PlanetProfile/Utilities/DataManip.py index d453dd35..86e4dc96 100644 --- a/PlanetProfile/Utilities/DataManip.py +++ b/PlanetProfile/Utilities/DataManip.py @@ -7,6 +7,7 @@ from PlanetProfile.Utilities.defineStructs import EOSlist from PlanetProfile.Utilities.defineStructs import Constants import logging +from scipy.interpolate import griddata # Assign logger log = logging.getLogger('PlanetProfile') @@ -33,7 +34,70 @@ def ResetNearestExtrap(var1, var2, min1, max1, min2, max2): return outVar1, outVar2 +def ReAssignPT(P_MPa, T_K, Pmin, Pmax, Tmin, Tmax, MELT=False, propsStepReductionFactor=1): + # Find indices where P_MPa is within bounds [Pmin, Pmax] + deltaP = np.mean(np.diff(P_MPa)) + deltaT = np.mean(np.diff(T_K)) + P_MPa = np.arange(Pmin, Pmax+deltaP, deltaP) + T_K = np.arange(Tmin, Tmax+deltaT, deltaT) + if MELT: + PropsP_MPa = np.linspace(P_MPa[0], P_MPa[-1], 10) + PropsT_K = np.linspace(T_K[0], T_K[-1], 11) + Pphase_MPa = P_MPa + Tphase_K = T_K + else: + PropsP_MPa = np.linspace(P_MPa[0], P_MPa[-1], int(np.ceil(np.size(P_MPa)/propsStepReductionFactor))) + PropsT_K = np.linspace(T_K[0], T_K[-1], int(np.ceil(np.size(T_K)/propsStepReductionFactor))) + Pphase_MPa = P_MPa + Tphase_K = T_K + return PropsP_MPa, PropsT_K, Pphase_MPa, Tphase_K + +def smoothGrid(x, y, zs, factor): + """ Smooth a grid by a factor """ + # Create finer resolution grid for smoothing + x_min, x_max = np.nanmin(x), np.nanmax(x) + y_min, y_max = np.nanmin(y), np.nanmax(y) + + # Ensure x and y are above machine error to prevent interpolation errors + if (x_min < 1e-10 and x_max < 1e-10) or (y_min < 1e-10 and y_max < 1e-10): + log.warning('x or y is below machine error. Smoothing will not be performed.') + x_fine = x + y_fine = y + else: + # Get original grid dimensions + original_x_size = x.shape[0] + original_y_size = x.shape[1] + + # Create finer grid with higher resolution + n_x_fine = original_x_size * factor + n_y_fine = original_y_size * factor + + # Create evenly spaced, strictly ascending fine grids + x_fine_1d = np.linspace(x_min, x_max, n_x_fine) + y_fine_1d = np.linspace(y_min, y_max, n_y_fine) + x_fine, y_fine = np.meshgrid(x_fine_1d, y_fine_1d, indexing='ij') + + # Flatten original data for interpolation + x_flat = x.flatten() + y_flat = y.flatten() + for i, z in enumerate(zs): + z_flat = z.flatten() + + # Remove invalid points for interpolation + valid_mask = ~np.isnan(z_flat) + x_valid = x_flat[valid_mask] + y_valid = y_flat[valid_mask] + z_valid = z_flat[valid_mask] + if len(z_valid) > 0: + + z_fine = griddata((x_valid, y_valid), z_valid, + (x_fine, y_fine), method='linear', fill_value=np.nan) + # Update x, y, z to use the finer resolution data + zs[i] = z_fine + else: + zs[i] = np.zeros((n_x_fine, n_y_fine)) * np.nan + return x_fine, y_fine, zs class ReturnZeros: """ Returns an array or tuple of arrays of zeros, for functions of properties not modeled that still work with querying routines. We have to run things @@ -113,7 +177,7 @@ def __init__(self, ppt, reference_ppt, pH, species): self.species_names = np.append(self.species_names,'H2O(aq)') self.speciation = np.append(self.speciation, 1/Constants.m_gmol['H2O']*1000) - def __call__(self, P, T, grid = False): + def __call__(self, P, T, grid = False, reactionSubstruct = None): nPs = np.size(P) nTs = np.size(T) pH = (np.zeros(nPs)) + self.pH @@ -126,7 +190,74 @@ def __call__(self, P, T, grid = False): species_names = np.array(self.species_names) return pH, speciation, species_names - +class Nearest2DInterpolator: + """Simplified version that may be faster for many use cases. + We use our own 2D interpolator to reduce memory overhead by allowing x, y, and z to keep their data types, rather than upcasting to float64 like scipy does. + This is particularly relevant for the phase lookup tables, where we can store the phase lookup table as a uint8 array.""" + def __init__(self, x, y, z): + self.x = np.asarray(x) + self.y = np.asarray(y) + self.z = np.asarray(z) + self.nx = len(x) + self.ny = len(y) + + def __call__(self, xi, yi, grid=False): + if grid: + return self._interpolate_grid(xi, yi) + else: + return self._interpolate(xi, yi) + + def _interpolate_grid(self, xi, yi): + """Fast grid interpolation without creating intermediate meshgrids""" + xi = np.asarray(xi) + yi = np.asarray(yi) + + # Find nearest indices for each dimension independently + ix = np.searchsorted(self.x, xi) + iy = np.searchsorted(self.y, yi) + + # Handle boundaries + ix_left = ix == 0 + iy_left = iy == 0 + + ix = np.minimum(ix, self.nx - 1) + iy = np.minimum(iy, self.ny - 1) + + # Distance comparisons + ix_adj = ~ix_left & ((xi - self.x[ix-1]) < (self.x[ix] - xi)) + iy_adj = ~iy_left & ((yi - self.y[iy-1]) < (self.y[iy] - yi)) + + ix -= ix_adj.astype(np.intp) + iy -= iy_adj.astype(np.intp) + + # Use advanced indexing to get grid result directly + # This creates the meshgrid effect without storing intermediate arrays + return self.z[np.ix_(ix, iy)] + + def _interpolate(self, xi, yi): + xi = np.asarray(xi) + yi = np.asarray(yi) + + # Binary search + ix = np.searchsorted(self.x, xi) + iy = np.searchsorted(self.y, yi) + + # Boundary handling with single operations + ix_left = ix == 0 + iy_left = iy == 0 + + # Clamp and adjust in one step + ix = np.minimum(ix, self.nx - 1) + iy = np.minimum(iy, self.ny - 1) + + # Distance comparison (avoid where ix/iy is 0) + ix_adj = ~ix_left & ((xi - self.x[ix-1]) < (self.x[ix] - xi)) + iy_adj = ~iy_left & ((yi - self.y[iy-1]) < (self.y[iy] - yi)) + + ix -= ix_adj.astype(np.intp) + iy -= iy_adj.astype(np.intp) + + return self.z[ix, iy] class EOSwrapper: @@ -181,11 +312,11 @@ def fn_sigma_Sm(self, P_MPa, T_K, grid=False): return EOSlist.loaded[self.key].fn_sigma_Sm(P_MPa, T_K, grid=grid) def fn_eta_Pas(self, P_MPa, T_K, grid=False): return EOSlist.loaded[self.key].fn_eta_Pas(P_MPa, T_K, grid=grid) + def updateConvectionViscosity(self, etaConv_Pas, Tconv_K): + return EOSlist.loaded[self.key].updateConvectionViscosity(etaConv_Pas, Tconv_K) def fn_Seismic(self, P_MPa, T_K, grid=False): return EOSlist.loaded[self.key].fn_Seismic(P_MPa, T_K, grid=grid) - def fn_species(self, P_MPa, T_K, grid = False): - return EOSlist.loaded[self.key].fn_species(P_MPa, T_K, grid=grid) - def fn_rxn_affinity(self, P_MPa, T_K, reaction, concentrations, grid = False): - return EOSlist.loaded[self.key].fn_rxn_affinity(P_MPa, T_K, reaction, concentrations, grid=grid) + def fn_species(self, P_MPa, T_K, grid = False, reactionSubstruct=None): + return EOSlist.loaded[self.key].fn_species(P_MPa, T_K, grid=grid, reactionSubstruct=reactionSubstruct) def fn_averageValuesAccordingtoRule(self, prop1, prop2, rule): return EOSlist.loaded[self.key].fn_averageValuesAccordingtoRule(prop1, prop2, rule) diff --git a/PlanetProfile/Utilities/ResultsIO.py b/PlanetProfile/Utilities/ResultsIO.py new file mode 100644 index 00000000..56f88ea1 --- /dev/null +++ b/PlanetProfile/Utilities/ResultsIO.py @@ -0,0 +1,422 @@ +""" +ResultsIO: Input/Output functions for PlanetProfile hierarchical results structures +This module contains functions for writing and reloading results using the new +hierarchical data structures, supporting both pickle and MATLAB formats. +""" + +import numpy as np +import os +import logging +import pickle +from scipy.io import savemat +from PlanetProfile.MagneticInduction.Moments import Excitations +from PlanetProfile.MagneticInduction.MagneticInduction import Benm2absBexyz +from PlanetProfile.GetConfig import Color, Style, FigLbl, FigSize, FigMisc + +# Assign logger +log = logging.getLogger('PlanetProfile') + +def WriteResults(Results, pklFilePath, saveMatlab = False, matlabFilePath=None): + """ + Save results to disk in pickle format (always) and optionally MATLAB format. + + Args: + results: MonteCarloResults, ExplorationResults, or InductionResults object + filepath (str): Base filepath (without extension) + save_matlab (bool): Whether to also save as .mat file + """ + with open(pklFilePath, 'wb') as f: + pickle.dump(Results, f) + log.info(f"Results saved to pickle file: {pklFilePath}") + + # Optionally save as MATLAB format for compatibility + if saveMatlab: + flat_dict = flatten_dict_for_matlab(Results) + savemat(matlabFilePath, flat_dict) + log.info(f"Results saved to MATLAB file: {matlabFilePath}") + + +def ReloadResultsFromPickle(fName): + """ + Reload results from pickle file. + + Args: + fName (str): Path to pickle file + + Returns: + MonteCarloResults, ExplorationResults, or InductionResults object + """ + if not os.path.isfile(fName): + raise FileNotFoundError(f"File {fName} not found.") + with open(fName, 'rb') as f: + results = pickle.load(f) + + log.info(f"Results loaded from pickle file: {fName}") + return results + + +def ExtractResults(Results, PlanetGrid, Params): + """ + Extract results from PlanetGrid. + """ + planetBodyName = PlanetGrid[0,0].name + if planetBodyName[:4] == 'Test': + bodyName = 'Test' + else: + bodyName = planetBodyName + + Results.bodyname = bodyName + Results.nx = PlanetGrid.shape[0] + Results.ny = PlanetGrid.shape[1] + Results.base = ExtractBasePlanetData(Results.base, PlanetGrid) + Results.induction = ExtractInductionData(Results.induction, Results.bodyname, PlanetGrid, Params) + # Set exploration-specific fields that don't fit the base pattern + Results.CMR2str = f'$C/MR^2 = {PlanetGrid[0,0].CMR2str}$' + Results.Cmeasured = PlanetGrid[0,0].Bulk.Cmeasured + Results.Cupper = PlanetGrid[0,0].Bulk.CuncertaintyUpper + Results.Clower = PlanetGrid[0,0].Bulk.CuncertaintyLower + + # Set grid information + Results.xName = Params.Explore.xName + Results.yName = Params.Explore.yName + Results.zName = Params.Explore.zName + Results.titleAddendum = FigLbl.titleAddendum + + # Rearrange x and y data to be 2D (for comaptibility with plotting functions) + Results.xData = np.tile(Results.xData, (Results.ny, 1)).T + Results.yData = np.tile(Results.yData, (Results.nx, 1)) + return Results + + +def ExtractBasePlanetData(baseStruct, PlanetGrid): + """ + Extract common base data from Planet objects. + Always returns 2D arrays for consistency across analysis types. + + Args: + PlanetArray: Array of Planet objects (1D for Monte Carlo, 2D for Exploration) + + Returns: + dict: Dictionary of extracted base data arrays (always 2D) + """ + # Force to 2D array if it's 1D (Monte Carlo case: reshape to 1 x N) + if PlanetGrid.ndim == 1: + PlanetGrid = PlanetGrid.reshape(1, -1) + + + base_data = { + # Identity and validity + 'bodyname': PlanetGrid[0,0].name, + 'VALID': np.array([[Planeti.Do.VALID if hasattr(Planeti.Do, 'VALID') else False for Planeti in line] for line in PlanetGrid]), + 'invalidReason': np.array([[getattr(Planeti, 'invalidReason', '') for Planeti in line] for line in PlanetGrid]), + 'NO_H2O': PlanetGrid[0,0].Do.NO_H2O, + + # Core results + 'CMR2mean': np.array([[getattr(Planeti, 'CMR2mean', np.nan) for Planeti in line] for line in PlanetGrid]), + 'Mtot_kg': np.array([[getattr(Planeti, 'Mtot_kg', np.nan) for Planeti in line] for line in PlanetGrid]), + 'D_km': np.array([[getattr(Planeti, 'D_km', np.nan) for Planeti in line] for line in PlanetGrid]), + 'zb_km': np.array([[getattr(Planeti, 'zb_km', np.nan) for Planeti in line] for line in PlanetGrid]), + 'Rcore_km': np.array([[getattr(Planeti.Core, 'Rmean_m', np.nan) / 1e3 if getattr(Planeti.Core, 'Rmean_m', np.nan) is not None else np.nan for Planeti in line] for line in PlanetGrid]), + + # Body structure + 'R_m': np.array([[Planeti.Bulk.R_m for Planeti in line] for line in PlanetGrid]), + 'zSeafloor_km': np.array([[getattr(Planeti, 'D_km', 0) + getattr(Planeti, 'zb_km', 0) for Planeti in line] for line in PlanetGrid]), + 'dzIceI_km': np.array([[getattr(Planeti, 'dzIceI_km', np.nan) for Planeti in line] for line in PlanetGrid]), + 'dzClath_km': np.array([[getattr(Planeti, 'dzClath_km', np.nan) for Planeti in line] for line in PlanetGrid]), + 'dzIceIII_km': np.array([[getattr(Planeti, 'dzIceIII_km', np.nan) for Planeti in line] for line in PlanetGrid]), + 'dzIceIIIund_km': np.array([[getattr(Planeti, 'dzIceIIIund_km', np.nan) for Planeti in line] for line in PlanetGrid]), + 'dzIceV_km': np.array([[getattr(Planeti, 'dzIceV_km', np.nan) for Planeti in line] for line in PlanetGrid]), + 'dzIceVund_km': np.array([[getattr(Planeti, 'dzIceVund_km', np.nan) for Planeti in line] for line in PlanetGrid]), + 'dzIceVI_km': np.array([[getattr(Planeti, 'dzIceVI_km', np.nan) for Planeti in line] for line in PlanetGrid]), + 'Dconv_m': np.array([[getattr(Planeti, 'Dconv_m', np.nan) for Planeti in line] for line in PlanetGrid]), + 'dzWetHPs_km': np.array([[getattr(Planeti, 'dzWetHPs_km', np.nan) for Planeti in line] for line in PlanetGrid]), + 'eLid_km': np.array([[getattr(Planeti, 'eLid_m', np.nan) / 1e3 if getattr(Planeti, 'eLid_m', np.nan) is not None else np.nan for Planeti in line] for line in PlanetGrid]), + + # Densities + 'rhoOceanMean_kgm3': np.array([[getattr(Planeti.Ocean, 'rhoMean_kgm3', np.nan) for Planeti in line] for line in PlanetGrid]), + 'rhoSilMean_kgm3': np.array([[getattr(Planeti.Sil, 'rhoMean_kgm3', np.nan) for Planeti in line] for line in PlanetGrid]), + 'rhoCoreMean_kgm3': np.array([[getattr(Planeti.Core, 'rhoMean_kgm3', np.nan) for Planeti in line] for line in PlanetGrid]), + + # Electrical properties + 'sigmaMean_Sm': np.array([[getattr(Planeti.Ocean, 'sigmaMean_Sm', np.nan) for Planeti in line] for line in PlanetGrid]), + 'sigmaTop_Sm': np.array([[getattr(Planeti.Ocean, 'sigmaTop_Sm', np.nan) for Planeti in line] for line in PlanetGrid]), + 'Tmean_K': np.array([[getattr(Planeti.Ocean, 'Tmean_K', np.nan) for Planeti in line] for line in PlanetGrid]), + 'oceanComp': np.array([[getattr(Planeti.Ocean, 'comp', '') for Planeti in line] for line in PlanetGrid]), + + # Love numbers + 'kLoveComplex': np.array([[getattr(Planeti.Gravity, 'k', np.nan) if hasattr(Planeti, 'Gravity') else np.nan for Planeti in line] for line in PlanetGrid]), + 'hLoveComplex': np.array([[getattr(Planeti.Gravity, 'h', np.nan) if hasattr(Planeti, 'Gravity') else np.nan for Planeti in line] for line in PlanetGrid]), + 'lLoveComplex': np.array([[getattr(Planeti.Gravity, 'l', np.nan) if hasattr(Planeti, 'Gravity') else np.nan for Planeti in line] for line in PlanetGrid]), + 'deltaLoveComplex': np.array([[getattr(Planeti.Gravity, 'delta', np.nan) if hasattr(Planeti, 'Gravity') else np.nan for Planeti in line] for line in PlanetGrid]), + 'kLoveAmp': np.array([[getattr(Planeti.Gravity, 'kAmp', np.nan) if hasattr(Planeti, 'Gravity') else np.nan for Planeti in line] for line in PlanetGrid]), + 'kLovePhase': np.array([[getattr(Planeti.Gravity, 'kPhase', np.nan) if hasattr(Planeti, 'Gravity') else np.nan for Planeti in line] for line in PlanetGrid]), + 'hLoveAmp': np.array([[getattr(Planeti.Gravity, 'hAmp', np.nan) if hasattr(Planeti, 'Gravity') else np.nan for Planeti in line] for line in PlanetGrid]), + 'hLovePhase': np.array([[getattr(Planeti.Gravity, 'hPhase', np.nan) if hasattr(Planeti, 'Gravity') else np.nan for Planeti in line] for line in PlanetGrid]), + 'lLoveAmp': np.array([[getattr(Planeti.Gravity, 'lAmp', np.nan) if hasattr(Planeti, 'Gravity') else np.nan for Planeti in line] for line in PlanetGrid]), + 'lLovePhase': np.array([[getattr(Planeti.Gravity, 'lPhase', np.nan) if hasattr(Planeti, 'Gravity') else np.nan for Planeti in line] for line in PlanetGrid]), + 'deltaLoveAmp': np.array([[getattr(Planeti.Gravity, 'deltaAmp', np.nan) if hasattr(Planeti, 'Gravity') else np.nan for Planeti in line] for line in PlanetGrid]), + 'deltaLovePhase': np.array([[getattr(Planeti.Gravity, 'deltaPhase', np.nan) if hasattr(Planeti, 'Gravity') else np.nan for Planeti in line] for line in PlanetGrid]), + + + # Seafloor and geochemistry + 'Pseafloor_MPa': np.array([[getattr(Planeti, 'Pseafloor_MPa', np.nan) for Planeti in line] for line in PlanetGrid]), + 'phiSeafloor_frac': np.array([[getattr(Planeti, 'phiSeafloor_frac', np.nan) for Planeti in line] for line in PlanetGrid]), + 'affinitySeafloor_kJ': np.array([[getattr(Planeti.Ocean, 'affinitySeafloor_kJ', np.nan) for Planeti in line] for line in PlanetGrid]), + 'affinityMean_kJ': np.array([[getattr(Planeti.Ocean, 'affinityMean_kJ', np.nan) for Planeti in line] for line in PlanetGrid]), + 'pHSeafloor': np.array([[getattr(Planeti.Ocean, 'pHSeafloor', np.nan) for Planeti in line] for line in PlanetGrid]), + 'pHTop': np.array([[getattr(Planeti.Ocean, 'pHTop', np.nan) for Planeti in line] for line in PlanetGrid]), + 'affinityTop_kJ': np.array([[getattr(Planeti.Ocean, 'affinityTop_kJ', np.nan) for Planeti in line] for line in PlanetGrid]), + 'speciesRatioToChange': np.array([[getattr(Planeti.Ocean.Reaction, 'speciesRatioToChange', np.nan) for Planeti in line] for line in PlanetGrid]), + 'mixingRatioToH2O': np.array([[getattr(Planeti.Ocean.Reaction, 'speciesToChangeMixingRatio', np.nan) for Planeti in line] for line in PlanetGrid]), + + # Porosity and rock properties + 'silPhiCalc_frac': np.array([[getattr(Planeti.Sil, 'phiCalc_frac', np.nan) for Planeti in line] for line in PlanetGrid]), + + # Input parameters (commonly varied) + 'wOcean_ppt': np.array([[Planeti.Ocean.wOcean_ppt for Planeti in line] for line in PlanetGrid]), + 'Tb_K': np.array([[Planeti.Bulk.Tb_K for Planeti in line] for line in PlanetGrid]), + 'zb_approximate_km': np.array([[getattr(Planeti.Bulk, 'zb_approximate_km', np.nan) for Planeti in line] for line in PlanetGrid]), + 'Pb_MPa': np.array([[getattr(Planeti.Bulk, 'Pb_MPa', np.nan) for Planeti in line] for line in PlanetGrid]), + 'rhoSil_kgm3': np.array([[getattr(Planeti.Sil, 'rhoSilWithCore_kgm3', np.nan) for Planeti in line] for line in PlanetGrid]), + 'rhoCore_kgm3': np.array([[getattr(Planeti.Core, 'rhoFe_kgm3', np.nan) for Planeti in line] for line in PlanetGrid]), + 'xFeS': np.array([[getattr(Planeti.Core, 'xFeS', np.nan) for Planeti in line] for line in PlanetGrid]), + 'silPhi_frac': np.array([[getattr(Planeti.Sil, 'phiRockMax_frac', np.nan) for Planeti in line] for line in PlanetGrid]), + 'icePhi_frac': np.array([[Planeti.Ocean.phiMax_frac.get('Ih', np.nan) if hasattr(Planeti.Ocean, 'phiMax_frac') and Planeti.Ocean.phiMax_frac else np.nan for Planeti in line] for line in PlanetGrid]), + 'silPclosure_MPa': np.array([[getattr(Planeti.Sil, 'Pclosure_MPa', np.nan) for Planeti in line] for line in PlanetGrid]), + 'icePclosure_MPa': np.array([[Planeti.Ocean.Pclosure_MPa.get('Ih', np.nan) if hasattr(Planeti.Ocean, 'Pclosure_MPa') and Planeti.Ocean.Pclosure_MPa else np.nan for Planeti in line] for line in PlanetGrid]), + 'ionosTop_km': np.array([[Planeti.Magnetic.ionosBounds_m[-1] / 1e3 if hasattr(Planeti, 'Magnetic') and hasattr(Planeti.Magnetic, 'ionosBounds_m') and Planeti.Magnetic.ionosBounds_m is not None else np.nan for Planeti in line] for line in PlanetGrid]), + 'sigmaIonos_Sm': np.array([[Planeti.Magnetic.sigmaIonosPedersen_Sm[-1] if hasattr(Planeti, 'Magnetic') and hasattr(Planeti.Magnetic, 'sigmaIonosPedersen_Sm') and Planeti.Magnetic.sigmaIonosPedersen_Sm is not None else np.nan for Planeti in line] for line in PlanetGrid]), + 'Htidal_Wm3': np.array([[getattr(Planeti.Sil, 'Htidal_Wm3', np.nan) for Planeti in line] for line in PlanetGrid]), + 'Qrad_Wkg': np.array([[getattr(Planeti.Sil, 'Qrad_Wkg', np.nan) for Planeti in line] for line in PlanetGrid]), + + # Additional common fields + 'qSurf_Wm2': np.array([[getattr(Planeti, 'qSurf_Wm2', np.nan) for Planeti in line] for line in PlanetGrid]), + } + + # Ensure everything is set so things will play nicely with .mat saving and plotting functions + nans = np.nan * base_data['R_m'] + for name, attr in base_data.items(): + if isinstance(attr, np.ndarray) and attr.ndim == 2 and np.all(attr == None): + base_data[name] = nans + setattr(baseStruct, name, base_data[name]) + for name, attr in baseStruct.__dict__.items(): + if attr is None: + baseStruct.__dict__[name] = nans + # Data is now always 2D - no need to flatten for Monte Carlo + return baseStruct + + +def ExtractInductionData(InductionResults, bodyname, PlanetGrid, Params): + """ + Extract induction-specific data from Planet objects. + Always returns 2D arrays for consistency across analysis types. + + Args: + PlanetArray: Array of Planet objects (1D for Monte Carlo, 2D for Exploration) + Bex_nT, Bey_nT, Bez_nT: External field components + + Returns: + dict: Dictionary of extracted induction data (always 2D) + #TODO Make this data compatible with multiple n's + """ + # Force to 2D array if it's 1D (Monte Carlo case: reshape to 1 x N) + if PlanetGrid.ndim == 1: + PlanetGrid = PlanetGrid.reshape(1, -1) + + BeList = Excitations(bodyname) + eachT = np.logical_and([Params.Induct.excSelectionCalc[key] for key in BeList.keys()], [BeList[key] is not None for key in BeList.keys()]) + nPeaks = sum(eachT) + # Extract magnetic induction results from the PlanetGrid + Benm_nT = PlanetGrid[0, 0].Magnetic.Benm_nT + # Organize data into a format that can be plotted/saved for plotting + Bex_nT, Bey_nT, Bez_nT = Benm2absBexyz(Benm_nT) + induction_data = { + 'nPeaks': nPeaks, + 'Amp': None, + 'Aen': None, + 'Phase': None, + 'Bix_nT': None, + 'Biy_nT': None, + 'Biz_nT': None, + 'Bi1xyz_nT': None, + 'rBi1x_nT': None, + 'rBi1y_nT': None, + 'rBi1z_nT': None, + 'iBi1x_nT': None, + 'iBi1y_nT': None, + 'iBi1z_nT': None, + 'Bi1Tot_nT': None, + 'rBi1Tot_nT': None, + 'iBi1Tot_nT': None, + 'calcedExc': None, + 'Texc_hr': None, + } + induction_data['calcedExc'] = {} + induction_data['Texc_hr'] = [] + if nPeaks > 0: + # Extract amplitude and phase data as 3D arrays (nPeaks x rows x cols) + # For 1D case, this will be nPeaks x 1 x N, then we'll flatten later + Amp_3d = np.full((nPeaks, PlanetGrid.shape[0], PlanetGrid.shape[1]), np.nan) + phase_3d = np.full((nPeaks, PlanetGrid.shape[0], PlanetGrid.shape[1]), np.nan) + Aen_3d = np.full((nPeaks, PlanetGrid.shape[0], PlanetGrid.shape[1]), np.nan, dtype=np.complex_) + # Create 3D arrays for Bi1xyz_nT of nPeaks x 2 (since complex number) x rows x cols + Bi1x_nT_3D = np.full((nPeaks, PlanetGrid.shape[0], PlanetGrid.shape[1]), np.nan, dtype=np.complex_) + Bi1y_nT_3D = np.full((nPeaks, PlanetGrid.shape[0], PlanetGrid.shape[1]), np.nan, dtype=np.complex_) + Bi1z_nT_3D = np.full((nPeaks, PlanetGrid.shape[0], PlanetGrid.shape[1]), np.nan, dtype=np.complex_) + Bi1Tot_3D = np.full((nPeaks, PlanetGrid.shape[0], PlanetGrid.shape[1]), np.nan, dtype=np.complex_) + for i, line in enumerate(PlanetGrid): + for j, Planet in enumerate(line): + if hasattr(Planet, 'Magnetic') and hasattr(Planet.Magnetic, 'Amp') and Planet.Magnetic.Amp is not None: + planet_amp = np.array(Planet.Magnetic.Amp) + planet_phase = np.array(Planet.Magnetic.phase) + planet_Aen = np.array(Planet.Magnetic.Aen[:, 1]) + Aen_3d[:nPeaks, i, j] = planet_Aen[:nPeaks] + Amp_3d[:nPeaks, i, j] = planet_amp[:nPeaks] + phase_3d[:nPeaks, i, j] = planet_phase[:nPeaks] + Bi1x_nT_3D[:nPeaks, i, j] = Planet.Magnetic.Bi1xyz_nT['x'][:] + Bi1y_nT_3D[:nPeaks, i, j] = Planet.Magnetic.Bi1xyz_nT['y'][:] + Bi1z_nT_3D[:nPeaks, i, j] = Planet.Magnetic.Bi1xyz_nT['z'][:] + Bi1Tot_3D[:nPeaks, i, j] = Planet.Magnetic.Bi1Tot_nT[:] + + # Set attributes that are consistent across all planets - in some cases where planets are invalid, they will not have this data, so this is why we only reset values for which their magnetic data has been calculated + induction_data['calcedExc'] = Planet.Magnetic.calcedExc + induction_data['Texc_hr'] = Planet.Magnetic.Texc_hr + + + induction_data['Amp'] = Amp_3d + induction_data['Phase'] = phase_3d + induction_data['Aen'] = Aen_3d + induction_data['Bi1x_nT'] = Bi1x_nT_3D + induction_data['Bi1y_nT'] = Bi1y_nT_3D + induction_data['Bi1z_nT'] = Bi1z_nT_3D + # Calculate induced field components + induction_data['Bix_nT'] = np.array([Amp_3d[i, ...] * Bex_nT[i] for i in range(nPeaks)]) + induction_data['Biy_nT'] = np.array([Amp_3d[i, ...] * Bey_nT[i] for i in range(nPeaks)]) + induction_data['Biz_nT'] = np.array([Amp_3d[i, ...] * Bez_nT[i] for i in range(nPeaks)]) + induction_data['rBi1x_nT'] = np.real(induction_data['Bi1x_nT']) + induction_data['rBi1y_nT'] = np.real(induction_data['Bi1y_nT']) + induction_data['rBi1z_nT'] = np.real(induction_data['Bi1z_nT']) + induction_data['iBi1x_nT'] = np.imag(induction_data['Bi1x_nT']) + induction_data['iBi1y_nT'] = np.imag(induction_data['Bi1y_nT']) + induction_data['iBi1z_nT'] = np.imag(induction_data['Bi1z_nT']) + induction_data['Bi1Tot_nT'] = Bi1Tot_3D + induction_data['rBi1Tot_nT'] = np.real(induction_data['Bi1Tot_nT']) + induction_data['iBi1Tot_nT'] = np.imag(induction_data['Bi1Tot_nT']) + + for key, value in induction_data.items(): + if hasattr(InductionResults, key): + setattr(InductionResults, key, value) + + # Data is now always 2D - consistent across all analysis types + return InductionResults + + +def InductionCalced(ExplorationList): + """ + Check if induction data exists for all results in the list. + """ + for Exploration in ExplorationList: + if Exploration.induction.Amp is None: + return False + return True + +def matlab_safe_key(key_name, max_length=31): + """ + Create MATLAB-safe variable name by truncating if necessary. + + MATLAB variable names must be 31 characters or less. This function + truncates long names while trying to preserve meaningful information. + + Args: + key_name (str): Original variable name + max_length (int): Maximum allowed length (default 31 for MATLAB) + + Returns: + str: Truncated variable name safe for MATLAB + """ + if len(key_name) <= max_length: + return key_name + + # Try to preserve meaningful parts by removing common suffixes/prefixes + truncated = key_name[:max_length] + + log.debug(f'Truncated MATLAB key "{key_name}" to "{truncated}"') + return truncated + + +def flatten_dict_for_matlab(obj, prefix='', flat_dict=None, max_key_length=31): + """ + Flatten an object's attributes into a MATLAB-compatible dictionary. + + This centralizes the flattening logic for all ResultsStruct objects. + + Args: + obj: ResultsStruct object to flatten + prefix (str): Prefix to add to attribute names (unused for top-level) + flat_dict (dict): Dictionary to add flattened items to (created if None) + max_key_length (int): Maximum key length for MATLAB compatibility + + Returns: + dict: Flattened dictionary suitable for savemat() + """ + if flat_dict is None: + flat_dict = {} + + def _flatten_object_attributes(source_obj, key_prefix): + """Helper to flatten attributes from a source object with given prefix.""" + for attr_name in dir(source_obj): + if not attr_name.startswith('_') and not callable(getattr(source_obj, attr_name)): + attr_value = getattr(source_obj, attr_name) + if attr_value is not None: + full_key = f'{key_prefix}{attr_name}' + safe_key = matlab_safe_key(full_key, max_key_length) + + # Convert data types for MATLAB compatibility + if isinstance(attr_value, (bool, np.bool_)): + flat_dict[safe_key] = int(attr_value) + elif isinstance(attr_value, np.ndarray): + flat_dict[safe_key] = attr_value + elif isinstance(attr_value, (list, tuple)): + flat_dict[safe_key] = np.array(attr_value) + elif isinstance(attr_value, dict): + # Handle dictionaries by flattening their contents + for dict_key, dict_value in attr_value.items(): + dict_full_key = f'{full_key}_{dict_key}' + dict_safe_key = matlab_safe_key(dict_full_key, max_key_length) + if isinstance(dict_value, (bool, np.bool_)): + flat_dict[dict_safe_key] = int(dict_value) + else: + flat_dict[dict_safe_key] = dict_value + else: + flat_dict[safe_key] = attr_value + possibleNestedResultStructs = {'base', 'induction', 'inversion'} + nestedResultsStructs = {} + for nestedResultStruct in possibleNestedResultStructs: + if hasattr(obj, nestedResultStruct): + nestedResultsStructs[nestedResultStruct] = getattr(obj, nestedResultStruct) + + # Flatten nested objects (base and induction) with their respective prefixes + for nestedResultStruct in nestedResultsStructs: + _flatten_object_attributes(nestedResultsStructs[nestedResultStruct], f'{nestedResultStruct}_') + + # Handle top-level attributes that are not nested objects + for attr_name in dir(obj): + # Skip private attributes (starting with '_'), nested objects, and callable methods + if (not attr_name.startswith('_') and + attr_name not in nestedResultsStructs.keys() and + not callable(getattr(obj, attr_name))): + attr_value = getattr(obj, attr_name) + # Only process attributes that have actual values (not None) + if attr_value is not None: + safe_key = matlab_safe_key(attr_name, max_key_length) + # Special handling for numpy arrays - flatten to 1D for MATLAB compatibility + if isinstance(attr_value, np.ndarray): + flat_dict[safe_key] = attr_value.flatten() + # Convert boolean values to integers (MATLAB doesn't handle booleans well) + elif isinstance(attr_value, (bool, np.bool_)): + flat_dict[safe_key] = int(attr_value) + # All other data types can be stored directly + else: + flat_dict[safe_key] = attr_value + + return flat_dict diff --git a/PlanetProfile/Utilities/ResultsStructs.py b/PlanetProfile/Utilities/ResultsStructs.py new file mode 100644 index 00000000..6d669c8a --- /dev/null +++ b/PlanetProfile/Utilities/ResultsStructs.py @@ -0,0 +1,338 @@ +""" +ResultsStructs: New hierarchical data structures for PlanetProfile results + +This module contains the new architecture for storing and managing results from +Monte Carlo, Exploration, and Inductogram analyses with a common base structure. + +DESIGN PRINCIPLE: All data arrays are stored in 2D format for consistency: +- Monte Carlo: shape (1, N_samples) - single "row" of N samples +- Exploration: shape (N_x, N_y) - actual 2D parameter grid +- Inductogram: shape (N_params, N_frequencies) - parameters vs frequencies + +This consistent 2D approach enables: +1. Unified plotting functions that work across all analysis types +2. Simpler data extraction without special case handling +3. Easy extensibility to new analysis types +4. Consistent flatten_for_matlab methods without complex reshaping +""" + +import numpy as np +import pickle +from scipy.io import savemat +import logging + +# Assign logger +log = logging.getLogger('PlanetProfile') + + + +class BaseResultsStruct: + """ + Common data structure for all analysis types containing shared results. + + This structure holds data that is common across Monte Carlo, Exploration, + and Inductogram analyses, eliminating duplication. + """ + + def __init__(self): + # Validity + self.VALID = None # Whether this profile is physically possible + self.invalidReason = None # Explanation for why any invalid solution failed + + # Core results + self.CMR2mean = None # Calculated C/MR^2 value + self.Mtot_kg = None # Total body mass in kg + self.D_km = None # Ocean layer thickness in km + self.zb_km = None # Ice shell thickness in km + self.Rcore_km = None # Core radius in km + + # Densities + self.rhoOceanMean_kgm3 = None # Mean ocean density in kg/m^3 + self.rhoSilMean_kgm3 = None # Mean silicate density in kg/m^3 + self.rhoCoreMean_kgm3 = None # Mean core density in kg/m^3 + + # Electrical properties + self.sigmaMean_Sm = None # Mean ocean conductivity in S/m + self.Tmean_K = None # Mean ocean temperature in K + self.oceanComp = None # Ocean composition + + + # Love numbers + self.kLoveComplex = None # k2 Love number + self.hLoveComplex = None # h2 Love number + self.lLoveComplex = None # l2 Love number + self.deltaLoveComplex = None # delta Love number + self.kLoveAmp = None # k2 Love number + self.hLoveAmp = None # h2 Love number + self.lLoveAmp = None # l2 Love number + self.deltaLoveAmp = None # delta Love number + self.kLovePhase = None # k2 Love number phase + self.hLovePhase = None # h2 Love number phase + self.lLovePhase = None # l2 Love number phase + self.deltaLovePhase = None # delta Love number phase + + # Input parameters (commonly varied) + self.wOcean_ppt = None # Ocean salinity in g/kg + self.Tb_K = None # Bottom temperature of ice shell in K + self.Pb_MPa = None # Bottom pressure of ice shell in MPa + self.rhoSil_kgm3 = None # Input silicate density in kg/m^3 + self.rhoCore_kgm3 = None # Input core density in kg/m^3 + + # Additional common fields from WriteExploreOgram and WriteMonteCarloResults + # Identity and basic info + self.NO_H2O = None # Whether this is a waterless body + + # Moment of inertia constraints + self.CMR2str = None # LaTeX-formatted string for moment of inertia + self.Cmeasured = None # Input moment of inertia to match + self.Cupper = None # Upper bound for valid MoI matches + self.Clower = None # Lower bound for valid MoI matches + + # Body structure + self.R_m = None # Body radius in m + + # Layer thicknesses (ice layers) + self.zSeafloor_km = None # Depth to bottom of ocean in km + self.dzIceI_km = None # Thickness of surface ice layer in km + self.dzClath_km = None # Thickness of clathrate layer in km + self.dzIceIII_km = None # Thickness of undersea ice III layer in km + self.dzIceIIIund_km = None # Thickness of underplate ice III layer in km + self.dzIceV_km = None # Thickness of undersea ice V layer in km + self.dzIceVund_km = None # Thickness of underplate ice V layer in km + self.dzIceVI_km = None # Thickness of undersea ice VI layer in km + self.dzWetHPs_km = None # Total thickness of all undersea high-pressure ices in km + self.eLid_km = None # Thickness of stagnant lid conductive ice layer in km + self.Dconv_m = None # Thickness of convective layer in m + + # Additional electrical properties + self.sigmaTop_Sm = None # Ocean top conductivity in S/m + + + # Seafloor and geochemistry + self.Pseafloor_MPa = None # Pressure at seafloor in MPa + self.phiSeafloor_frac = None # Rock porosity at seafloor + self.affinitySeafloor_kJ = None # Available chemical energy at seafloor + self.affinityMean_kJ = None # Mean available chemical energy + self.pHSeafloor = None # pH at the seafloor + self.pHTop = None # pH at the top of the ocean + self.affinityTop_kJ = None # Available chemical energy at top of ocean + + # Porosity and rock properties + self.silPhiCalc_frac = None # Calculated rock porosity (best-match P=0 value) + + # Additional input parameters from exploration + self.zb_approximate_km = None # Approximate ice shell thickness input + self.xFeS = None # Core FeS mole fraction + self.rhoSilInput_kgm3 = None # Input silicate density (alternative name for rhoSil_kgm3) + self.silPhi_frac = None # Silicate porosity fraction + self.icePhi_frac = None # Ice porosity fraction + self.silPclosure_MPa = None # Silicate pore closure pressure + self.icePclosure_MPa = None # Ice pore closure pressure + self.ionosTop_km = None # Ionosphere top altitude + self.sigmaIonos_Sm = None # Ionosphere conductivity + self.Htidal_Wm3 = None # Tidal heating rate + self.Qrad_Wkg = None # Radiogenic heating rate + +class InversionData: + def __init__(self): + self.gridWithinAllUncertainty = None + self.gridWithinInductionResponseUncertainty = None + self.gridWithinkLoveAmpUncertainty = None + self.gridWithinkLovePhaseUncertainty = None + self.gridWithinhLoveAmpUncertainty = None + self.gridWithinhLovePhaseUncertainty = None + +class InductionData: + """ + Standardized induction results structure. + + This holds magnetic induction calculation results in a consistent format + across all analysis types. + """ + + def __init__(self): + self.Amp = None # Amplitude of dipole response (modulus of complex response) + self.Phase = None # Phase delay in degrees (positive) + self.Bix_nT = None # Induced magnetic field x-component in nT + self.Biy_nT = None # Induced magnetic field y-component in nT + self.Biz_nT = None # Induced magnetic field z-component in nT + self.Bi1x_nT = None # Complex induced magnetic field x-component in nT + self.Bi1y_nT = None # Complex induced magnetic field y-component in nT + self.Bi1z_nT = None # Complex induced magnetic field z-component in nT + self.rBi1x_nT = None # Real part of complex induced magnetic field x-component in nT + self.rBi1y_nT = None # Real part of complex induced magnetic field y-component in nT + self.rBi1z_nT = None # Real part of complex induced magnetic field z-component in nT + self.iBi1x_nT = None # Imaginary part of complex induced magnetic field x-component in nT + self.iBi1y_nT = None # Imaginary part of complex induced magnetic field y-component in nT + self.iBi1z_nT = None # Imaginary part of complex induced magnetic field z-component in nT + self.Bi1Tot_nT = None # Total complex induced magnetic field in nT + self.rBi1Tot_nT = None # Real part of total complex induced magnetic field in nT + self.iBi1Tot_nT = None # Imaginary part of total complex induced magnetic field in nT + self.Texc_hr = None # Excitation period in hours + self.freq_Hz = None # Excitation frequency in Hz + self.period_hr = None # Excitation period in hours (same as Texc_hr for consistency) + + # Additional fields from Monte Carlo results + self.calcedExc = None # List of excitations calculated + self.nPeaks = None # Number of peaks in magnetic response + self.excSelectionCalc = None # Excitation selection calculation results + +class MonteCarloStatistics: + """ + Monte Carlo specific statistical data. + + This holds information specific to Monte Carlo analyses including + run statistics and parameter sampling information. + """ + + def __init__(self): + # Run statistics + self.nRuns = None # Number of Monte Carlo runs performed + self.nSuccess = None # Number of successful runs + self.successRate = None # Success rate as a fraction + self.seed = None # Random seed used for reproducibility + + # Parameter information + self.paramsToSearch = None # List of parameter names that were varied + self.paramsRanges = None # Dictionary of parameter ranges used + self.paramsDistributions = None # Dictionary of distribution types used + self.paramValues = None # Dictionary of parameter name: array of sampled values + + # Timing information + self.totalTime_s = None # Total execution time in seconds + self.avgTime_s = None # Average time per model in seconds + + +class MonteCarloResultsStruct: + """ + Container for Monte Carlo analysis results. + + This combines base results with Monte Carlo-specific statistics and provides + methods for data management and export. + """ + + def __init__(self): + self.base = BaseResultsStruct() # Common results data + self.induction = InductionData() # Induction results (if applicable) + self.statistics = MonteCarloStatistics() # Monte Carlo specific data + + # Additional MC-specific results arrays + self.runtimePerModel_s = None # Array of runtime per model in seconds + + + +class ExplorationResultsStruct: + """ + Container for Exploration analysis results. + + This combines base results with grid data and provides methods for + data management and export. + """ + + def __init__(self): + self.bodyname = None # Name of body modeled + self.base = BaseResultsStruct() # Common results data + self.induction = InductionData() # Induction results (if applicable) + self.inversion = InversionData() # Inversion results (if applicable) + + # Grid data stored directly in ExplorationResults (no separate GridData class) + self.xData = None # 2D grid data for x-axis variable + self.yData = None # 2D grid data for y-axis variable + self.xName = None # Name of x-axis variable + self.yName = None # Name of y-axis variable + self.nx = None # Size of x-axis variable + self.ny = None # Size of y-axis variable + self.xUnits = None # Units for x-axis variable + self.yUnits = None # Units for y-axis variable + self.xScale = 'linear' # Scale type for x-axis ('linear' or 'log') + self.yScale = 'linear' # Scale type for y-axis ('linear' or 'log') + self.titleAddendum = None # Additional title information + + # Additional exploration-specific results + self.zName = None # Name of z-axis variable + self.CMR2str = None # LaTeX-formatted string for moment of inertia + self.Cmeasured = None # Input moment of inertia to match + self.Cupper = None # Upper bound for valid MoI matches + self.Clower = None # Lower bound for valid MoI matches + + self.excName = None # Name of excitation variable + + + +class InductionResultsStruct: + """ + Container for Inductogram analysis results. + + This combines base results with induction data and provides methods for + data management and export. + """ + + def __init__(self): + self.base = BaseResultsStruct() # Common results data + self.induction = InductionData() # Induction results + + self.bodyname = None # Name of body modeled. + self.yName = None # Name of variable along y axis. Options are "Tb", "phi", "rho", "sigma", where the first 3 are vs. salinity, and sigma is vs. thickness. + self.Texc_hr = None # Dict of excitation periods modeled. + self.Amp = None # Amplitude of dipole response (modulus of complex dipole response). + self.Phase = None # (Positive) phase delay in degrees. + self.Bix_nT = None # Induced Bx dipole moments relative to body surface in nT for each excitation. + self.Biy_nT = None # Induced By dipole moments relative to body surface in nT for each excitation. + self.Biz_nT = None # Induced Bz dipole moments relative to body surface in nT for each excitation. + self.wOcean_ppt = None # Values of salinity used. + self.oceanComp = None # Ocean composition used. + self.Tb_K = None # Values of Bulk.Tb_K used. + self.rhoSilMean_kgm3 = None # Values of Sil.rhoMean_kgm3 resulted (also equal to those set for all but phi inductOtype). + self.phiRockMax_frac = None # Values of Sil.phiRockMax_frac set. + self.Tmean_K = None # Ocean mean temperature result in K. + self.sigmaMean_Sm = None # Mean ocean conductivity. Used to map plots vs. salinity onto D/sigma plots. + self.sigmaTop_Sm = None # Ocean top conductivity. Used to map plots vs. salinity onto D/sigma plots. + self.D_km = None # Ocean layer thickness in km. Used to map plots vs. salinity onto D/sigma plots. + self.zb_km = None # Upper ice shell thickness in km. + self.R_m = None # Body radius in m, used to scale amplitudes. + self.rBds_m = None # Conducting layer upper boundaries in m. + self.sigmaLayers_Sm = None # Conductivities below each boundary in S/m. + self.zb_approximate_km = None # Upper approximate ice shell thickness in km. + self.oceanComp = None # Ocean compositions + + + self.x = None # Variable to plot on x axis of inductogram plots + self.y = None # Variable to plot on y axis of inductogram plots + self.nx = None # Size of x-axis variable + self.ny = None # Size of y-axis variable + self.compsList = None # Linear list of compositions for each model point + self.comps = None # Minimal list of compositions, with 1 entry per comp + self.SINGLE_COMP = None # Boolean flag for tracking if all of the models have the same composition + + def SetAxes(self, inductOtype): + # Set the x and y variables to plot in inductograms based on inductOtype + if inductOtype == 'sigma': + self.x = self.sigmaMean_Sm + self.y = self.D_km + elif inductOtype == 'oceanComp': + self.x = self.oceanComp + self.y = self.zb_approximate_km + else: + self.x = self.wOcean_ppt + if inductOtype == 'Tb': + self.y = self.Tb_K + elif inductOtype == 'rho': + self.y = self.rhoSilMean_kgm3 + elif inductOtype == 'phi': + self.y = self.phiRockMax_frac + else: + raise ValueError(f'inductOtype {inductOtype} not recognized.') + + def SetComps(self, inductOtype): + # Set some attributes pertaining to handling multiple ocean compositions in plots + self.compsList = self.oceanComp.flatten() + # Change any array with CustomSolution to just CustomSolution + for i in range(self.compsList.size): + if 'CustomSolution' in self.compsList[i]: + self.compsList[i] = 'CustomSolution' + if np.all(self.compsList == self.compsList[0]) and inductOtype != 'sigma': + self.SINGLE_COMP = True + self.comps = [self.compsList[0]] + else: + self.SINGLE_COMP = False + self.comps = np.unique(self.compsList) \ No newline at end of file diff --git a/PlanetProfile/Utilities/SetupInit.py b/PlanetProfile/Utilities/SetupInit.py index 97ecd0de..658ca4cc 100644 --- a/PlanetProfile/Utilities/SetupInit.py +++ b/PlanetProfile/Utilities/SetupInit.py @@ -4,24 +4,38 @@ import numpy as np import logging from datetime import datetime +import time from collections.abc import Iterable from PlanetProfile import _ROOT from PlanetProfile.GetConfig import Color, Style, FigLbl, FigMisc -from PlanetProfile.Thermodynamics.HydroEOS import GetOceanEOS, GetIceEOS, GetTfreeze +from PlanetProfile.Thermodynamics.HydroEOS import GetOceanEOS, GetIceEOS, GetOceanEOSLabel, GetIceEOSLabel from PlanetProfile.Thermodynamics.InnerEOS import GetInnerEOS -from PlanetProfile.Thermodynamics.Reaktoro.CustomSolution import SetupCustomSolution, strip_latex_formatting_from_CustomSolutionLabel +from PlanetProfile.Thermodynamics.Reaktoro.CustomSolution import SetupCustomSolution, strip_latex_formatting_from_CustomSolutionLabel, SetupCustomSolutionEOS from PlanetProfile.Thermodynamics.Clathrates.ClathrateProps import ClathDissoc +from copy import deepcopy from PlanetProfile.Utilities.PPversion import ppVerNum, CheckCompat from PlanetProfile.Utilities.defineStructs import DataFilesSubstruct, FigureFilesSubstruct, Constants from PlanetProfile.TrajecAnalysis import _MAGdir, _scList from PlanetProfile.TrajecAnalysis.FlybyEvents import scTargets from PlanetProfile.TrajecAnalysis.RefileMAGdata import RefileName, MAGtoHDF5, LoadMAG +from PlanetProfile.Utilities.Indexing import PhaseInv + +# Parallel processing +import multiprocessing as mtp +import platform +plat = platform.system() +if plat == 'Windows': + mtpType = 'spawn' +else: + mtpType = 'fork' +mtpContext = mtp.get_context(mtpType) # Assign logger log = logging.getLogger('PlanetProfile') def SetupInit(Planet, Params): - + # Set timer start + Planet.profileStartTime = time.time() # Print version number log.debug(f'-- PlanetProfile {ppVerNum} --') if ppVerNum[-3:] == 'dev': log.debug('This version is in development.') @@ -91,7 +105,7 @@ def SetupInit(Planet, Params): # Generate zero-yielding ocean "EOS" for use in porosity calculations # Note that there must be enough input points for creating spline # interpolators, even though we will not use them. - Planet.Ocean.EOS = GetOceanEOS('none', None, np.linspace(0, 1, 10), np.linspace(0, 1, 10), None) + Planet.Ocean.EOS = GetOceanEOS(compstr = 'none', wOcean_ppt = None, P_MPa = np.linspace(0, 1, 10), T_K = np.linspace(0, 1, 10), elecType = None) else: if Planet.Do.NO_DIFFERENTIATION: @@ -137,296 +151,332 @@ def SetupInit(Planet, Params): f'fluids are modeled for partial/undifferentiated bodies. ' + f'Sil.wPore_ppt will be ignored.') Planet.Sil.wPore_ppt = Planet.Ocean.wOcean_ppt + if Planet.Do.POROUS_ROCK: + if Planet.Ocean.wOcean_ppt is None and Planet.Sil.wPore_ppt is None: + raise ValueError(f'Either Ocean.wOcean_ppt or Sil.wPore_ppt must be set.') + elif Planet.Ocean.wOcean_ppt is None: + Planet.Ocean.wOcean_ppt = Planet.Sil.wPore_ppt + elif Planet.Sil.wPore_ppt is None: + Planet.Sil.wPore_ppt = Planet.Ocean.wOcean_ppt # Get filenames for saving/loading Planet, Params.DataFiles, Params.FigureFiles = SetupFilenames(Planet, Params) - - # Set steps and settings for unused options to zero, check that we have settings we need - # Core settings - if Planet.Do.Fe_CORE: - # (Re)set a predefined core radius, i.e. from CONSTANT_INNER_DENSITY, so that we can - # check if it's None to see if we used that option. - Planet.Core.Rset_m = None + if Planet.Do.NON_SELF_CONSISTENT: + SetupNonSelfConsistent(Planet, Params) else: - Planet.Steps.nCore = 0 - - # Clathrates - if Planet.Do.CLATHRATE: - if Planet.Bulk.clathType is None: - raise ValueError('Clathrate model type must be set. Options are "top", "bottom", and "whole".') - elif Planet.Bulk.clathType == 'whole': - # Pick whichever number of layers is greater, if the user has set Steps.nClath. - # This allows the user to skip setting Steps.nClath separately if they just want - # to model whole-shell clathrates with other standard run settings. - if Planet.Steps.nClath is None: Planet.Steps.nClath = 0 - Planet.Steps.nClath = np.maximum(Planet.Steps.nIceI, Planet.Steps.nClath) - Planet.Steps.nIceI = 0 + # Set steps and settings for unused options to zero, check that we have settings we need + # Core settings + if Planet.Do.Fe_CORE: + # (Re)set a predefined core radius, i.e. from CONSTANT_INNER_DENSITY, so that we can + # check if it's None to see if we used that option. + Planet.Core.Rset_m = None else: - if Planet.Bulk.clathMaxThick_m is None: - raise ValueError('Bulk.clathMaxThick_m must be set for this clathType model.') - elif Planet.Steps.nClath is None: - raise ValueError('Steps.nClath must be set for this clathType model.') - elif Planet.Bulk.clathType == 'bottom': - if Planet.Bulk.qSurf_Wm2 is None: - raise ValueError('Bulk.qSurf_Wm2 must be set for this clathType model.') - if not Planet.Do.NO_ICE_CONVECTION: - log.warning('Do.NO_ICE_CONVECTION is False, but convection is incompatible with clathrate underplating. ' + - 'Do.NO_ICE_CONVECTION will be forced on.') - Planet.Do.NO_ICE_CONVECTION = True - Planet.Ocean.ClathDissoc = ClathDissoc(Planet.Bulk.Tb_K, NAGASHIMA_CLATH_DISSOC=Planet.Do.NAGASHIMA_CLATH_DISSOC, - ALLOW_BROKEN_MODELS=Params.ALLOW_BROKEN_MODELS, - DO_EXPLOREOGRAM=Params.DO_EXPLOREOGRAM) - else: - Planet.Steps.nClath = 0 - Planet.zClath_m = 0 - Planet.Bulk.clathType = 'none' + Planet.Steps.nCore = 0 - if not (Planet.Do.NO_OCEAN and Planet.Do.NO_ICE_CONVECTION): - # In addition, perform some checks on underplating settings to be sure they make sense - if not Planet.Do.BOTTOM_ICEIII and not Planet.Do.BOTTOM_ICEV: - Planet.Steps.nIceIIILitho = 0 - Planet.Steps.nIceVLitho = 0 + # Clathrates + if Planet.Do.CLATHRATE: + if Planet.Bulk.clathType is None: + raise ValueError('Clathrate model type must be set. Options are "top", "bottom", and "whole".') + elif Planet.Bulk.clathType == 'whole': + # Pick whichever number of layers is greater, if the user has set Steps.nClath. + # This allows the user to skip setting Steps.nClath separately if they just want + # to model whole-shell clathrates with other standard run settings. + if Planet.Steps.nClath is None: Planet.Steps.nClath = 0 + Planet.Steps.nClath = np.maximum(Planet.Steps.nIceI, Planet.Steps.nClath) + Planet.Steps.nIceI = 0 + else: + if Planet.Bulk.clathMaxThick_m is None: + raise ValueError('Bulk.clathMaxThick_m must be set for this clathType model.') + elif Planet.Steps.nClath is None: + raise ValueError('Steps.nClath must be set for this clathType model.') + elif Planet.Bulk.clathType == 'bottom': + if Planet.Bulk.qSurf_Wm2 is None: + raise ValueError('Bulk.qSurf_Wm2 must be set for this clathType model.') + if not Planet.Do.NO_ICE_CONVECTION: + log.warning('Do.NO_ICE_CONVECTION is False, but convection is incompatible with clathrate underplating. ' + + 'Do.NO_ICE_CONVECTION will be forced on.') + Planet.Do.NO_ICE_CONVECTION = True + Planet.Ocean.ClathDissoc = ClathDissoc(Planet.Bulk.Tb_K, NAGASHIMA_CLATH_DISSOC=Planet.Do.NAGASHIMA_CLATH_DISSOC, + ALLOW_BROKEN_MODELS=Params.ALLOW_BROKEN_MODELS, + DO_EXPLOREOGRAM=Params.DO_EXPLOREOGRAM) + else: + Planet.Steps.nClath = 0 + Planet.zClath_m = 0 + Planet.Bulk.clathType = 'none' + + if not Planet.Do.NO_OCEAN or Planet.Do.NO_OCEAN_EXCEPT_INNER_ICES: + # Even if are we not modeling an ocean, if we are still modeling inner HP ices we need to get the ocean EOS for the phase diagram + # In addition, perform some checks on underplating settings to be sure they make sense + if not Planet.Do.BOTTOM_ICEIII and not Planet.Do.BOTTOM_ICEV: + Planet.Steps.nIceIIILitho = 0 + Planet.Steps.nIceVLitho = 0 + Planet.RaConvectIII = np.nan + Planet.RaConvectV = np.nan + Planet.RaCritIII = np.nan + Planet.RaCritV = np.nan + Planet.eLidIII_m = 0 + Planet.eLidV_m = 0 + Planet.DconvIII_m = 0 + Planet.DconvV_m = 0 + Planet.deltaTBLIII_m = 0 + Planet.deltaTBLV_m = 0 + elif not Planet.Do.BOTTOM_ICEV: + Planet.Steps.nIceVLitho = 0 + Planet.RaConvectV = np.nan + Planet.RaCritV = np.nan + Planet.eLidV_m = 0 + Planet.DconvV_m = 0 + Planet.deltaTBLV_m = 0 + if Planet.Ocean.PHydroMax_MPa < 209.9: + raise ValueError('Hydrosphere max pressure is less than the pressure of the ice I-III phase transition, ' + + 'but Do.BOTTOM_ICEIII is True.') + if Planet.Bulk.Tb_K > Planet.Bulk.TbIII_K: + log.warning('Bottom temperature of ice I (Tb_K) is greater than bottom temperature of underplate ' + + 'ice III (TbIII_K). This likely represents a non-equilibrium state.') + else: + if Planet.Ocean.PHydroMax_MPa < 344.3: + raise ValueError('Hydrosphere max pressure is less than the pressure of the ice III-V phase transition, ' + + 'but Do.BOTTOM_ICEV is True.') + if Planet.Bulk.Tb_K > Planet.Bulk.TbIII_K: + log.warning('Bottom temperature of ice I (Tb_K) is greater than bottom temperature of underplate ' + + 'ice III (TbIII_K). This likely represents a non-equilibrium state.') + if Planet.Bulk.TbIII_K > Planet.Bulk.TbV_K: + log.warning('Bottom temperature of ice III (Tb_K) is greater than bottom temperature of underplate ' + + 'ice V (TbV_K). This likely represents a non-equilibrium state.') + if Planet.Do.CLATHRATE: + log.warning('Clathrates are stable under a very large range of pressures and temperatures, and this ' + + 'may be contradictory with having underplating ice III or V.') + + # Make sure ocean max temp is above melting temp + if Planet.Do.ICEIh_THICKNESS: + if Planet.Ocean.THydroMax_K <= Planet.TfreezeUpper_K: + Planet.Ocean.THydroMax_K = Planet.TfreezeUpper_K + 30 + else: + if Planet.Ocean.THydroMax_K <= Planet.Bulk.Tb_K: + Planet.Ocean.THydroMax_K = Planet.Bulk.Tb_K + 30 + + # Get ocean EOS functions + POcean_MPa = np.arange(Planet.PfreezeLower_MPa, Planet.Ocean.PHydroMax_MPa, Planet.Ocean.deltaP) + # Set Ocean.deltaT to use default of 0.1 K but recommend the user to set + if Planet.Ocean.deltaT is None: + Planet.Ocean.deltaT = 1e-1 + log.warning('Ocean.deltaT is not set--defaulting to 0.1 K. This may not be precise enough ' + + 'for shallow oceans or fine control over ice shell thickness calculations. ' + + 'It is recommended to set Ocean.deltaT manually in the PPBody.py file.') + if Planet.Do.ICEIh_THICKNESS: + TOcean_K = np.arange(Planet.TfreezeLower_K, Planet.Ocean.THydroMax_K, Planet.Ocean.deltaT) + else: + TOcean_K = np.arange(Planet.Bulk.Tb_K, Planet.Ocean.THydroMax_K, Planet.Ocean.deltaT) + if not Params.PRELOAD_EOS_IN_PROGRESS: + # If we are loading large EOS tables, we will generate a larger EOS table in the PrecomputeEOS function + Planet.Ocean.EOS = GetOceanEOS(Planet.Ocean.comp, Planet.Ocean.wOcean_ppt, POcean_MPa, TOcean_K, + Planet.Ocean.MgSO4elecType, rhoType=Planet.Ocean.MgSO4rhoType, + scalingType=Planet.Ocean.MgSO4scalingType, FORCE_NEW=Params.FORCE_EOS_RECALC, + phaseType=Planet.Ocean.phaseType, EXTRAP=Params.EXTRAP_OCEAN, + sigmaFixed_Sm=Planet.Ocean.sigmaFixed_Sm, LOOKUP_HIRES=Planet.Do.OCEAN_PHASE_HIRES, kThermConst_WmK=Planet.Ocean.kThermWater_WmK, + propsStepReductionFactor=Planet.Ocean.propsStepReductionFactor) + + if Planet.Ocean.EOS.deltaP < Planet.Ocean.deltaP: + log.debug(f'Updating Ocean.deltaP to match the more refined EOS.deltaP ({Planet.Ocean.EOS.deltaP}).') + Planet.Ocean.deltaP = Planet.Ocean.EOS.deltaP + if Planet.Ocean.EOS.deltaT < Planet.Ocean.deltaT: + log.debug(f'Updating Ocean.deltaT to match the more refined EOS.deltaT ({Planet.Ocean.EOS.deltaT}).') + Planet.Ocean.deltaT = Planet.Ocean.EOS.deltaT + + # Get separate, simpler EOS for evaluating the melting curve + if Planet.Do.ICEIh_THICKNESS: + Tmelt_K = np.arange(Planet.TfreezeLower_K, Planet.TfreezeUpper_K, Planet.TfreezeRes_K) + else: + Planet.Bulk.zb_approximate_km = np.nan + if Planet.Do.BOTTOM_ICEV: + # Make sure Tb values are physically reasonable + if Planet.Bulk.Tb_K > Planet.Bulk.TbV_K or Planet.Bulk.Tb_K > Planet.Bulk.TbIII_K: + raise ValueError('Bulk.Tb_K must be less than underplate layer Tb values.') + if Planet.Bulk.TbIII_K > Planet.Bulk.TbV_K: + raise ValueError('Bulk.TbIII_K must be less than underplate TbV_K value.') + TmeltI_K = np.arange(Planet.Bulk.Tb_K - Planet.TfreezeRes_K*2, Planet.Bulk.Tb_K + Planet.TfreezeRes_K*2, Planet.TfreezeRes_K) + TmeltIII_K = np.arange(Planet.Bulk.TbIII_K - Planet.TfreezeRes_K*2, Planet.Bulk.TbIII_K + Planet.TfreezeRes_K*2, Planet.TfreezeRes_K) + TmeltV_K = np.arange(Planet.Bulk.TbV_K - Planet.TfreezeRes_K*2, Planet.Bulk.TbV_K + Planet.TfreezeRes_K*2, Planet.TfreezeRes_K) + Tmelt_K = np.concatenate((TmeltI_K, TmeltIII_K, TmeltV_K)) + if Planet.PfreezeUpper_MPa < 700: + log.info(f'PfreezeUpper_MPa is set to {Planet.PfreezeUpper_MPa}, but Do.BOTTOM_ICEV is True and ' + + 'ice V is stable up to 700 MPa. PfreezeUpper_MPa will be raised to this value.') + Planet.PfreezeUpper_MPa = 700 + elif Planet.Do.BOTTOM_ICEIII: + if Planet.Bulk.Tb_K > Planet.Bulk.TbIII_K: + raise ValueError('Bulk.Tb_K must be less than underplate TbIII_K value.') + TmeltI_K = np.arange(Planet.Bulk.Tb_K - Planet.TfreezeRes_K*2, Planet.Bulk.Tb_K + Planet.TfreezeRes_K*2, Planet.TfreezeRes_K) + TmeltIII_K = np.arange(Planet.Bulk.TbIII_K - Planet.TfreezeRes_K*2, Planet.Bulk.TbIII_K + Planet.TfreezeRes_K*2, Planet.TfreezeRes_K) + Tmelt_K = np.concatenate((TmeltI_K, TmeltIII_K)) + if Planet.PfreezeUpper_MPa < 700: + log.info(f'PfreezeUpper_MPa is set to {Planet.PfreezeUpper_MPa}, but Do.BOTTOM_ICEIII is True and ' + + 'ice V is stable up to 400 MPa. PfreezeUpper_MPa will be raised to this value.') + Planet.PfreezeUpper_MPa = 400 + else: + Tmelt_K = np.arange(Planet.Bulk.Tb_K - Planet.TfreezeRes_K*2, Planet.Bulk.Tb_K + Planet.TfreezeRes_K*2, Planet.TfreezeRes_K) + Pmelt_MPa = np.arange(Planet.PfreezeLower_MPa, Planet.PfreezeUpper_MPa, Planet.PfreezeRes_MPa) + if not Params.PRELOAD_EOS_IN_PROGRESS: + Planet.Ocean.meltEOS = GetOceanEOS(Planet.Ocean.comp, Planet.Ocean.wOcean_ppt, Pmelt_MPa, Tmelt_K, None, + phaseType=Planet.Ocean.phaseType, FORCE_NEW=(not (Params.DO_EXPLOREOGRAM and Params.PRELOAD_EOS) and not(Params.DO_MONTECARLO and Params.PRELOAD_EOS) and not(Params.DO_INDUCTOGRAM and Params.PRELOAD_EOS)), MELT=True, + LOOKUP_HIRES=Planet.Do.OCEAN_PHASE_HIRES, propsStepReductionFactor=1) + + # Setup parameters + for phase in Planet.Ocean.Eact_kJmol.keys(): + if Planet.Ocean.Eact_kJmol[phase] is np.nan: + Planet.Ocean.Eact_kJmol[phase] = Constants.Eact_kJmol[PhaseInv(phase)] + + # Make sure convection checking outputs are set if we won't be modeling them + if Planet.Do.NO_ICE_CONVECTION: + Planet.RaConvect = np.nan Planet.RaConvectIII = np.nan Planet.RaConvectV = np.nan + Planet.RaCrit = np.nan Planet.RaCritIII = np.nan Planet.RaCritV = np.nan - Planet.eLidIII_m = 0 - Planet.eLidV_m = 0 - Planet.DconvIII_m = 0 - Planet.DconvV_m = 0 - Planet.deltaTBLIII_m = 0 - Planet.deltaTBLV_m = 0 - elif not Planet.Do.BOTTOM_ICEV: - Planet.Steps.nIceVLitho = 0 - Planet.RaConvectV = np.nan - Planet.RaCritV = np.nan - Planet.eLidV_m = 0 - Planet.DconvV_m = 0 - Planet.deltaTBLV_m = 0 - if Planet.Ocean.PHydroMax_MPa < 209.9: - raise ValueError('Hydrosphere max pressure is less than the pressure of the ice I-III phase transition, ' + - 'but Do.BOTTOM_ICEIII is True.') - if Planet.Bulk.Tb_K > Planet.Bulk.TbIII_K: - log.warning('Bottom temperature of ice I (Tb_K) is greater than bottom temperature of underplate ' + - 'ice III (TbIII_K). This likely represents a non-equilibrium state.') - else: - if Planet.Ocean.PHydroMax_MPa < 344.3: - raise ValueError('Hydrosphere max pressure is less than the pressure of the ice III-V phase transition, ' + - 'but Do.BOTTOM_ICEV is True.') - if Planet.Bulk.Tb_K > Planet.Bulk.TbIII_K: - log.warning('Bottom temperature of ice I (Tb_K) is greater than bottom temperature of underplate ' + - 'ice III (TbIII_K). This likely represents a non-equilibrium state.') - if Planet.Bulk.TbIII_K > Planet.Bulk.TbV_K: - log.warning('Bottom temperature of ice III (Tb_K) is greater than bottom temperature of underplate ' + - 'ice V (TbV_K). This likely represents a non-equilibrium state.') - if Planet.Do.CLATHRATE: - log.warning('Clathrates are stable under a very large range of pressures and temperatures, and this ' + - 'may be contradictory with having underplating ice III or V.') - - # Make sure ocean max temp is above melting temp - if Planet.Ocean.THydroMax_K <= Planet.Bulk.Tb_K: - Planet.Ocean.THydroMax_K = Planet.Bulk.Tb_K + 30 - - # Get ocean EOS functions - POcean_MPa = np.arange(Planet.PfreezeLower_MPa, Planet.Ocean.PHydroMax_MPa, Planet.Ocean.deltaP) - # Set Ocean.deltaT to use default of 0.1 K but recommend the user to set - if Planet.Ocean.deltaT is None: - Planet.Ocean.deltaT = 1e-1 - log.warning('Ocean.deltaT is not set--defaulting to 0.1 K. This may not be precise enough ' + - 'for shallow oceans or fine control over ice shell thickness calculations. ' + - 'It is recommended to set Ocean.deltaT manually in the PPBody.py file.') - # Check ocean parameter space and load EOS - if Planet.Ocean.THydroMax_K < Planet.Bulk.Tb_K: - raise ValueError(f'Ocean.THydroMax_K of {Planet.Ocean.THydroMax_K} is less than Bulk.Tb_K of {Planet.Bulk.Tb_K}.') - elif Planet.Do.ICEIh_THICKNESS: - TOcean_K = np.arange(Planet.TfreezeLower_K, Planet.Ocean.THydroMax_K, Planet.Ocean.deltaT) - else: - TOcean_K = np.arange(Planet.Bulk.Tb_K, Planet.Ocean.THydroMax_K, Planet.Ocean.deltaT) - Planet.Ocean.EOS = GetOceanEOS(Planet.Ocean.comp, Planet.Ocean.wOcean_ppt, POcean_MPa, TOcean_K, - Planet.Ocean.MgSO4elecType, rhoType=Planet.Ocean.MgSO4rhoType, - scalingType=Planet.Ocean.MgSO4scalingType, FORCE_NEW=Params.FORCE_EOS_RECALC, - phaseType=Planet.Ocean.phaseType, EXTRAP=Params.EXTRAP_OCEAN, - sigmaFixed_Sm=Planet.Ocean.sigmaFixed_Sm, LOOKUP_HIRES=Planet.Do.OCEAN_PHASE_HIRES) - if Planet.Ocean.EOS.deltaP != Planet.Ocean.deltaP: - log.debug(f'Updating Ocean.deltaP to match the more refined EOS.deltaP ({Planet.Ocean.EOS.deltaP}).') - Planet.Ocean.deltaP = Planet.Ocean.EOS.deltaP - if Planet.Ocean.EOS.deltaT != Planet.Ocean.deltaT: - log.debug(f'Updating Ocean.deltaT to match the more refined EOS.deltaT ({Planet.Ocean.EOS.deltaT}).') - Planet.Ocean.deltaT = Planet.Ocean.EOS.deltaT - - # Get separate, simpler EOS for evaluating the melting curve - if Planet.Do.ICEIh_THICKNESS: - Tmelt_K = np.arange(Planet.TfreezeLower_K, Planet.TfreezeUpper_K, Planet.TfreezeRes_K) - else: - Planet.Bulk.zb_approximate_km = np.nan - if Planet.Do.BOTTOM_ICEV: - # Make sure Tb values are physically reasonable - if Planet.Bulk.Tb_K > Planet.Bulk.TbV_K or Planet.Bulk.Tb_K > Planet.Bulk.TbIII_K: - raise ValueError('Bulk.Tb_K must be less than underplate layer Tb values.') - if Planet.Bulk.TbIII_K > Planet.Bulk.TbV_K: - raise ValueError('Bulk.TbIII_K must be less than underplate TbV_K value.') - TmeltI_K = np.linspace(Planet.Bulk.Tb_K - 0.01, Planet.Bulk.Tb_K + 0.01, 11) - TmeltIII_K = np.linspace(Planet.Bulk.TbIII_K - 0.01, Planet.Bulk.TbIII_K + 0.01, 11) - TmeltV_K = np.linspace(Planet.Bulk.TbV_K - 0.01, Planet.Bulk.TbV_K + 0.01, 11) - Tmelt_K = np.concatenate((TmeltI_K, TmeltIII_K, TmeltV_K)) - if Planet.PfreezeUpper_MPa < 700: - log.info(f'PfreezeUpper_MPa is set to {Planet.PfreezeUpper_MPa}, but Do.BOTTOM_ICEV is True and ' + - 'ice V is stable up to 700 MPa. PfreezeUpper_MPa will be raised to this value.') - Planet.PfreezeUpper_MPa = 700 - elif Planet.Do.BOTTOM_ICEIII: - if Planet.Bulk.Tb_K > Planet.Bulk.TbIII_K: - raise ValueError('Bulk.Tb_K must be less than underplate TbIII_K value.') - TmeltI_K = np.linspace(Planet.Bulk.Tb_K - 0.01, Planet.Bulk.Tb_K + 0.01, 11) - TmeltIII_K = np.linspace(Planet.Bulk.TbIII_K - 0.01, Planet.Bulk.TbIII_K + 0.01, 11) - Tmelt_K = np.concatenate((TmeltI_K, TmeltIII_K)) - if Planet.PfreezeUpper_MPa < 700: - log.info(f'PfreezeUpper_MPa is set to {Planet.PfreezeUpper_MPa}, but Do.BOTTOM_ICEIII is True and ' + - 'ice V is stable up to 400 MPa. PfreezeUpper_MPa will be raised to this value.') - Planet.PfreezeUpper_MPa = 400 - else: - Tmelt_K = np.linspace(Planet.Bulk.Tb_K - 0.01, Planet.Bulk.Tb_K + 0.01, 11) - Pmelt_MPa = np.arange(Planet.PfreezeLower_MPa, Planet.PfreezeUpper_MPa, Planet.PfreezeRes_MPa) - Planet.Ocean.meltEOS = GetOceanEOS(Planet.Ocean.comp, Planet.Ocean.wOcean_ppt, Pmelt_MPa, Tmelt_K, None, - phaseType=Planet.Ocean.phaseType, FORCE_NEW=True, MELT=True, - LOOKUP_HIRES=Planet.Do.OCEAN_PHASE_HIRES) - - # Make sure convection checking outputs are set if we won't be modeling them - if Planet.Do.NO_ICE_CONVECTION: - Planet.RaConvect = np.nan - Planet.RaConvectIII = np.nan - Planet.RaConvectV = np.nan - Planet.RaCrit = np.nan - Planet.RaCritIII = np.nan - Planet.RaCritV = np.nan - Planet.Tconv_K = np.nan - Planet.TconvIII_K = np.nan - Planet.TconvV_K = np.nan - Planet.eLid_m = 0.0 - Planet.eLidIII_m = 0.0 - Planet.eLidV_m = 0.0 - Planet.Dconv_m = 0.0 - Planet.DconvIII_m = 0.0 - Planet.DconvV_m = 0.0 - Planet.deltaTBL_m = 0.0 - Planet.deltaTBLIII_m = 0.0 - Planet.deltaTBLV_m = 0.0 - - # Porous rock - if Planet.Do.POROUS_ROCK: - # Make sure pore max pressure is set, since we will need it to check if we'll need HP ices - if not Planet.Do.PORE_EOS_DIFFERENT and Planet.Sil.PHydroMax_MPa is None: - Planet.Sil.PHydroMax_MPa = Planet.Ocean.PHydroMax_MPa + Planet.Tconv_K = np.nan + Planet.TconvIII_K = np.nan + Planet.TconvV_K = np.nan + Planet.eLid_m = 0.0 + Planet.eLidIII_m = 0.0 + Planet.eLidV_m = 0.0 + Planet.Dconv_m = 0.0 + Planet.DconvIII_m = 0.0 + Planet.DconvV_m = 0.0 + Planet.deltaTBL_m = 0.0 + Planet.deltaTBLIII_m = 0.0 + Planet.deltaTBLV_m = 0.0 - if Planet.Sil.porosType == 'Han2014': - if Planet.Sil.phiRockMax_frac is None: - log.warning('Sil.phiRockMax_frac is not set. Using arbitrary max porosity of 0.7.') - Planet.Sil.phiRockMax_frac = 0.7 + # Porous rock + if Planet.Do.POROUS_ROCK: + # Make sure pore max pressure is set, since we will need it to check if we'll need HP ices + if not Planet.Do.PORE_EOS_DIFFERENT and Planet.Sil.PHydroMax_MPa is None: + Planet.Sil.PHydroMax_MPa = Planet.Ocean.PHydroMax_MPa + + if Planet.Sil.porosType == 'Han2014': + if Planet.Sil.phiRockMax_frac is None: + log.warning('Sil.phiRockMax_frac is not set. Using arbitrary max porosity of 0.7.') + Planet.Sil.phiRockMax_frac = 0.7 + else: + Planet.Do.FIXED_POROSITY = True + if Planet.Sil.phiRangeMult <= 1: + raise ValueError(f'Sil.phiRangeMult = {Planet.Sil.phiRangeMult}, but it must be greater than 1.') + # Ensure we set pore comp to ocean comp if not specified as different + if Planet.Sil.poreComp is None: + Planet.Sil.poreComp = Planet.Ocean.comp + Planet.Do.PORE_EOS_DIFFERENT = False + else: + Planet.Do.PORE_EOS_DIFFERENT = True + if Planet.Sil.wPore_ppt is None: + Planet.Sil.wPore_ppt = Planet.Ocean.wOcean_ppt + Planet.Do.PORE_EOS_DIFFERENT = False + else: + Planet.Do.PORE_EOS_DIFFERENT = True else: - Planet.Do.FIXED_POROSITY = True - if Planet.Sil.phiRangeMult <= 1: - raise ValueError(f'Sil.phiRangeMult = {Planet.Sil.phiRangeMult}, but it must be greater than 1.') - # Ensure we set pore comp to ocean comp if not specified as different - if Planet.Sil.poreComp is None: + if (not Planet.Do.Fe_CORE) and (not Planet.Do.CONSTANT_INNER_DENSITY): + raise RuntimeError('Matching the body MoI requires either a core or porosity in the rock.' + + 'Set Planet.Do.POROUS_ROCK to True and rerun to continue modeling with no core.') + Planet.Sil.porosType = 'none' + Planet.Sil.poreH2Orho_kgm3 = 0 + Planet.Sil.phiRockMax_frac = 0 Planet.Sil.poreComp = Planet.Ocean.comp - Planet.Do.PORE_EOS_DIFFERENT = False - else: - Planet.Do.PORE_EOS_DIFFERENT = True - if Planet.Sil.wPore_ppt is None: Planet.Sil.wPore_ppt = Planet.Ocean.wOcean_ppt - Planet.Do.PORE_EOS_DIFFERENT = False + + # Porous ice + if not Planet.Do.POROUS_ICE: + Planet.Ocean.phiMax_frac = {key:0 for key in Planet.Ocean.phiMax_frac.keys()} + Planet.Ocean.porosType = {key:None for key in Planet.Ocean.porosType.keys()} + + # Effective pressure in pore space + if not Planet.Do.P_EFFECTIVE: + # Peffective is calculated from Pmatrix - alpha*Ppore, so setting alpha to zero avoids the need for repeated + # conditional checks during layer propagation -- calculations are typically faster than conditional checks. + if Planet.Sil.alphaPeff != 0: + log.debug('Sil.alphaPeff was not 0, but is being set to 0 because Do.P_EFFECTIVE is False.') + Planet.Sil.alphaPeff = 0 + for phase in Planet.Ocean.alphaPeff.keys(): + if Planet.Ocean.alphaPeff[phase] != 0: + log.debug(f'Ocean.alphaPeff[{phase}] was not 0, but is being set to 0 because Do.P_EFFECTIVE is False.') + Planet.Ocean.alphaPeff[phase] = 0 + + # Calculate bulk density from total mass and radius, and warn user if they specified density + if Planet.Bulk.M_kg is None: + Planet.Bulk.M_kg = Planet.Bulk.rho_kgm3 * (4/3*np.pi * Planet.Bulk.R_m**3) else: - Planet.Do.PORE_EOS_DIFFERENT = True - else: - if (not Planet.Do.Fe_CORE) and (not Planet.Do.CONSTANT_INNER_DENSITY): - raise RuntimeError('Matching the body MoI requires either a core or porosity in the rock.' + - 'Set Planet.Do.POROUS_ROCK to True and rerun to continue modeling with no core.') - Planet.Sil.porosType = 'none' - Planet.Sil.poreH2Orho_kgm3 = 0 - Planet.Sil.phiRockMax_frac = 0 - Planet.Sil.poreComp = Planet.Ocean.comp - Planet.Sil.wPore_ppt = Planet.Ocean.wOcean_ppt - - # Porous ice - if not Planet.Do.POROUS_ICE: - Planet.Ocean.phiMax_frac = {key:0 for key in Planet.Ocean.phiMax_frac.keys()} - Planet.Ocean.porosType = {key:None for key in Planet.Ocean.porosType.keys()} - - # Effective pressure in pore space - if not Planet.Do.P_EFFECTIVE: - # Peffective is calculated from Pmatrix - alpha*Ppore, so setting alpha to zero avoids the need for repeated - # conditional checks during layer propagation -- calculations are typically faster than conditional checks. - if Planet.Sil.alphaPeff != 0: - log.debug('Sil.alphaPeff was not 0, but is being set to 0 because Do.P_EFFECTIVE is False.') - Planet.Sil.alphaPeff = 0 - for phase in Planet.Ocean.alphaPeff.keys(): - if Planet.Ocean.alphaPeff[phase] != 0: - log.debug(f'Ocean.alphaPeff[{phase}] was not 0, but is being set to 0 because Do.P_EFFECTIVE is False.') - Planet.Ocean.alphaPeff[phase] = 0 - - # Calculate bulk density from total mass and radius, and warn user if they specified density - if Planet.Bulk.M_kg is None: - Planet.Bulk.M_kg = Planet.Bulk.rho_kgm3 * (4/3*np.pi * Planet.Bulk.R_m**3) - else: - if Planet.Bulk.rho_kgm3 is not None and not Params.DO_EXPLOREOGRAM: - log.warning('Both bulk mass and density were specified. Only one is required--' + - 'density will be recalculated from bulk mass for consistency.') - Planet.Bulk.rho_kgm3 = Planet.Bulk.M_kg / (4/3*np.pi * Planet.Bulk.R_m**3) - - # Load EOS functions for deeper interior - if not Params.SKIP_INNER: - # Get silicate EOS - Planet.Sil.EOS = GetInnerEOS(Planet.Sil.mantleEOS, EOSinterpMethod=Params.lookupInterpMethod, - kThermConst_WmK=Planet.Sil.kTherm_WmK, HtidalConst_Wm3=Planet.Sil.Htidal_Wm3, - porosType=Planet.Sil.porosType, phiTop_frac=Planet.Sil.phiRockMax_frac, - Pclosure_MPa=Planet.Sil.Pclosure_MPa, phiMin_frac=Planet.Sil.phiMin_frac, - EXTRAP=Params.EXTRAP_SIL) - - # Pore fluids if present - if Planet.Do.POROUS_ROCK: - if Planet.Do.NO_H2O: - Ppore_MPa, Tpore_K = (np.linspace(0, 1, 10) for _ in range(2)) - else: - if Planet.Sil.poreComp == 'Seawater' and Planet.Sil.PHydroMax_MPa > 300: - log.warning('GSW yields NaN for Cp at pressures above 300 MPa. Reducing PsilMax to this value.') - Planet.Sil.PHydroMax_MPa = 300 - Ppore_MPa = np.linspace(Planet.Bulk.Psurf_MPa, Planet.Sil.PHydroMax_MPa, 100) - Tpore_K = np.linspace(Planet.Bulk.Tb_K, Planet.Sil.THydroMax_K, 140) - # Get pore fluid EOS - Planet.Sil.poreEOS = GetOceanEOS(Planet.Sil.poreComp, Planet.Sil.wPore_ppt, Ppore_MPa, Tpore_K, - Planet.Ocean.MgSO4elecType, rhoType=Planet.Ocean.MgSO4rhoType, - scalingType=Planet.Ocean.MgSO4scalingType, FORCE_NEW=Params.FORCE_EOS_RECALC, - phaseType=Planet.Ocean.phaseType, EXTRAP=Params.EXTRAP_OCEAN, PORE=True, - sigmaFixed_Sm=Planet.Sil.sigmaPoreFixed_Sm, LOOKUP_HIRES=Planet.Do.OCEAN_PHASE_HIRES) - if Planet.Do.NO_DIFFERENTIATION or Planet.Do.PARTIAL_DIFFERENTIATION: - Planet.Ocean.EOS = Planet.Sil.poreEOS - for icePhase in ['Ih', 'II', 'III', 'V', 'VI']: - Planet.Ocean.surfIceEOS[icePhase] = GetIceEOS(Ppore_MPa, Tpore_K, icePhase, - EXTRAP=Params.EXTRAP_ICE[icePhase], - ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT) - - # Make sure Sil.phiRockMax_frac is set in case we're using a porosType that doesn't require it - if Planet.Sil.phiRockMax_frac is None or Planet.Sil.porosType != 'Han2014': - Planet.Sil.phiRockMax_frac = Planet.Sil.EOS.fn_phi_frac(0, 0) - if Planet.Sil.phiRockMax_frac > 1.0: - raise ValueError(f'A maximum rock porosity value of {Planet.Sil.phiRockMax_frac}' + - 'has been set. Values greater than 1 are unphysical--check input' + - 'file settings.') - - # Iron core if present - if Planet.Do.Fe_CORE: - Planet.Core.EOS = GetInnerEOS(Planet.Core.coreEOS, EOSinterpMethod=Params.lookupInterpMethod, Fe_EOS=True, + if Planet.Bulk.rho_kgm3 is not None and not Params.DO_EXPLOREOGRAM: + log.warning('Both bulk mass and density were specified. Only one is required--' + + 'density will be recalculated from bulk mass for consistency.') + Planet.Bulk.rho_kgm3 = Planet.Bulk.M_kg / (4/3*np.pi * Planet.Bulk.R_m**3) + + # Load EOS functions for deeper interior + if not Params.SKIP_INNER: + if not Params.PRELOAD_EOS_IN_PROGRESS: + # Get silicate EOS + Planet.Sil.EOS = GetInnerEOS(Planet.Sil.mantleEOS, EOSinterpMethod=Params.lookupInterpMethod, + kThermConst_WmK=Planet.Sil.kTherm_WmK, HtidalConst_Wm3=Planet.Sil.Htidal_Wm3, + porosType=Planet.Sil.porosType, phiTop_frac=Planet.Sil.phiRockMax_frac, + Pclosure_MPa=Planet.Sil.Pclosure_MPa, phiMin_frac=Planet.Sil.phiMin_frac, + EXTRAP=Params.EXTRAP_SIL, etaSilFixed_Pas=Planet.Sil.etaRock_Pas, etaCoreFixed_Pas=[Planet.Core.etaFeSolid_Pas, Planet.Core.etaFeLiquid_Pas], + TviscTrans_K=Planet.Sil.TviscTrans_K, + doConstantProps=Planet.Do.CONSTANT_INNER_DENSITY, constantProperties={'rho_kgm3': Planet.Sil.rhoSilWithCore_kgm3, 'Cp_JkgK': np.nan, 'alpha_pK': np.nan, 'kTherm_WmK': Planet.Sil.kTherm_WmK, + 'VP_kms': Planet.Sil.VPset_kms, 'VS_kms': Planet.Sil.VSset_kms, 'KS_GPa': Planet.Sil.KSset_GPa, 'GS_GPa': Planet.Sil.GSset_GPa, 'eta_Pas': Planet.Sil.etaRock_Pas, + 'sigma_Sm': Planet.Sil.sigmaSil_Sm}) + + # Pore fluids if present + if Planet.Do.POROUS_ROCK: + if Planet.Do.NO_H2O: + Ppore_MPa, Tpore_K = (np.linspace(0, 1, 10) for _ in range(2)) + else: + if Planet.Sil.poreComp == 'Seawater' and Planet.Sil.PHydroMax_MPa > 300: + log.warning('GSW yields NaN for Cp at pressures above 300 MPa. Reducing PsilMax to this value.') + Planet.Sil.PHydroMax_MPa = 300 + Ppore_MPa = np.arange(Planet.Bulk.Psurf_MPa, Planet.Sil.PHydroMax_MPa + Planet.Ocean.deltaP, Planet.Ocean.deltaP) + Tpore_K = np.arange(Planet.Bulk.Tb_K, Planet.Sil.THydroMax_K + Planet.Ocean.deltaT, Planet.Ocean.deltaT) + # Get pore fluid EOS + if not Params.PRELOAD_EOS_IN_PROGRESS: + Planet.Sil.poreEOS = GetOceanEOS(Planet.Sil.poreComp, Planet.Sil.wPore_ppt, Ppore_MPa, Tpore_K, + Planet.Ocean.MgSO4elecType, rhoType=Planet.Ocean.MgSO4rhoType, + scalingType=Planet.Ocean.MgSO4scalingType, FORCE_NEW=Params.FORCE_EOS_RECALC, + phaseType=Planet.Ocean.phaseType, EXTRAP=Params.EXTRAP_OCEAN, PORE=True, + sigmaFixed_Sm=Planet.Sil.sigmaPoreFixed_Sm, LOOKUP_HIRES=Planet.Do.OCEAN_PHASE_HIRES, kThermConst_WmK=Planet.Ocean.kThermWater_WmK, + propsStepReductionFactor=Planet.Ocean.propsStepReductionFactor) + + if Planet.Do.NO_DIFFERENTIATION or Planet.Do.PARTIAL_DIFFERENTIATION: + Planet.Ocean.EOS = Planet.Sil.poreEOS + for icePhase in ['Ih', 'II', 'III', 'V', 'VI']: + if not Params.PRELOAD_EOS_IN_PROGRESS: + Planet.Ocean.surfIceEOS[icePhase] = GetIceEOS(Ppore_MPa, Tpore_K, icePhase, + EXTRAP=Params.EXTRAP_ICE[icePhase], + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, kThermConst_WmK=Planet.Ocean.kThermIce_WmK, + minPres_MPa=Params.minPres_MPa, minTres_K=Params.minTres_K) + + # Make sure Sil.phiRockMax_frac is set in case we're using a porosType that doesn't require it + if Planet.Sil.phiRockMax_frac is None or Planet.Sil.porosType != 'Han2014': + Planet.Sil.phiRockMax_frac = Planet.Sil.EOS.fn_phi_frac(0, 0) + if Planet.Sil.phiRockMax_frac > 1.0: + raise ValueError(f'A maximum rock porosity value of {Planet.Sil.phiRockMax_frac}' + + 'has been set. Values greater than 1 are unphysical--check input' + + 'file settings.') + + # Iron core if present + if Planet.Do.Fe_CORE: + if not Params.PRELOAD_EOS_IN_PROGRESS: + Planet.Core.EOS = GetInnerEOS(Planet.Core.coreEOS, EOSinterpMethod=Params.lookupInterpMethod, Fe_EOS=True, kThermConst_WmK=Planet.Core.kTherm_WmK, EXTRAP=Params.EXTRAP_Fe, - wFeCore_ppt=Planet.Core.wFe_ppt, wScore_ppt=Planet.Core.wS_ppt) - - # Ensure ionosphere bounds and conductivity are in a format we expect - if Planet.Magnetic.ionosBounds_m is None: - Planet.Magnetic.ionosBounds_m = [np.nan] - elif not isinstance(Planet.Magnetic.ionosBounds_m, Iterable): - Planet.Magnetic.ionosBounds_m = [Planet.Magnetic.ionosBounds_m] - if Planet.Magnetic.sigmaIonosPedersen_Sm is None: - Planet.Magnetic.sigmaIonosPedersen_Sm = [np.nan] - elif not isinstance(Planet.Magnetic.sigmaIonosPedersen_Sm, Iterable): - Planet.Magnetic.sigmaIonosPedersen_Sm = [Planet.Magnetic.sigmaIonosPedersen_Sm] + wFeCore_ppt=Planet.Core.wFe_ppt, wScore_ppt=Planet.Core.wS_ppt, etaSilFixed_Pas=Planet.Sil.etaRock_Pas, etaCoreFixed_Pas=[Planet.Core.etaFeSolid_Pas, Planet.Core.etaFeLiquid_Pas], + TviscTrans_K=Planet.Core.TviscTrans_K, + doConstantProps=Planet.Do.CONSTANT_INNER_DENSITY, constantProperties={'rho_kgm3': Planet.Core.rhoFe_kgm3, 'Cp_JkgK': np.nan, 'alpha_pK': np.nan, 'kTherm_WmK': Planet.Core.kTherm_WmK, + 'VP_kms': np.nan, 'VS_kms': np.nan, 'KS_GPa': np.nan, 'GS_GPa': Planet.Core.GSset_GPa, 'eta_Pas': Planet.Core.etaFeSolid_Pas, + 'sigma_Sm': Planet.Core.sigmaCore_Sm}) + + # Ensure ionosphere bounds and conductivity are in a format we expect + if Planet.Magnetic.ionosBounds_m is None: + Planet.Magnetic.ionosBounds_m = [np.nan] + elif not isinstance(Planet.Magnetic.ionosBounds_m, Iterable): + Planet.Magnetic.ionosBounds_m = [Planet.Magnetic.ionosBounds_m] + if Planet.Magnetic.sigmaIonosPedersen_Sm is None: + Planet.Magnetic.sigmaIonosPedersen_Sm = [np.nan] + elif not isinstance(Planet.Magnetic.sigmaIonosPedersen_Sm, Iterable): + Planet.Magnetic.sigmaIonosPedersen_Sm = [Planet.Magnetic.sigmaIonosPedersen_Sm] # Preallocate layer physical quantity arrays Planet = SetupLayers(Planet) @@ -490,7 +540,7 @@ def SetupInversion(Params): return Params, magData -def SetupFilenames(Planet, Params, exploreAppend=None, figExploreAppend=None): +def SetupFilenames(Planet, Params, exploreAppend=None, figExploreAppend=None, monteCarloAppend=None): """ Generate filenames for saving data and figures. """ datPath = Planet.bodyname @@ -515,99 +565,108 @@ def SetupFilenames(Planet, Params, exploreAppend=None, figExploreAppend=None): saveLabel = '' label = '' comp = Planet.Ocean.comp - if Planet.Do.NO_H2O: - saveLabel += f'NoH2O_qSurf{Planet.Bulk.qSurf_Wm2*1e3:.1f}mWm2' - setStr = f'$q_\mathrm{{surf}}\,{Planet.Bulk.qSurf_Wm2*FigLbl.qMult:.1f}\,\si{{{FigLbl.fluxUnits}}}$' - label += setStr - Planet.compStr = r'No~\ce{H2O}' - - elif Planet.Do.NO_DIFFERENTIATION: - saveLabel += f'NoDiff_qSurf{Planet.Bulk.qSurf_Wm2*1e3:.1f}mWm2' - setStr = f'$q_\mathrm{{surf}}\,{Planet.Bulk.qSurf_Wm2*FigLbl.qMult:.1f}\,\si{{{FigLbl.fluxUnits}}}$' - label += setStr - Planet.compStr = r'Undifferentiated' - - elif Planet.Do.PARTIAL_DIFFERENTIATION: - saveLabel += f'PartialDiff_qSurf{Planet.Bulk.qSurf_Wm2*1e3:.1f}mWm2' - setStr = f'$q_\mathrm{{surf}}\,{Planet.Bulk.qSurf_Wm2*FigLbl.qMult:.1f}\,\si{{{FigLbl.fluxUnits}}}$' - label += setStr - - if Planet.Sil.poreComp == 'PureH2O': - Planet.compStr = r'Pure~\ce{H2O}' - saveLabel += f'_{Planet.Sil.poreComp}Pores' - else: - Planet.compStr = f'${Planet.Sil.wPore_ppt*FigLbl.wMult:.1f}\,\si{{{FigLbl.wUnits}}}$~\ce{{{Planet.Sil.poreComp}}}' - saveLabel += f'_{Planet.Sil.poreComp}_{Planet.Sil.wPore_ppt:.1f}pptPores' + if Planet.Do.NON_SELF_CONSISTENT: + saveLabel = 'NonSelfConsistent_' + setStr = 'Non-self-consistent' + else: + if Planet.Do.NO_H2O: + saveLabel += f'NoH2O_qSurf{Planet.Bulk.qSurf_Wm2*1e3:.1f}mWm2' + setStr = f'$q_\mathrm{{surf}}\,{Planet.Bulk.qSurf_Wm2*FigLbl.qMult:.1f}\,\si{{{FigLbl.fluxUnits}}}$' + label += setStr + Planet.compStr = r'No~\ce{H2O}' - saveLabel += f'_PorousRock_phi{Planet.Sil.phiRockMax_frac:.2f}_Pc{Planet.Sil.Pclosure_MPa:5.2e}' - label += ' w/$\phi_\mathrm{sil}$' + elif Planet.Do.NO_DIFFERENTIATION: + saveLabel += f'NoDiff_qSurf{Planet.Bulk.qSurf_Wm2*1e3:.1f}mWm2' + setStr = f'$q_\mathrm{{surf}}\,{Planet.Bulk.qSurf_Wm2*FigLbl.qMult:.1f}\,\si{{{FigLbl.fluxUnits}}}$' + label += setStr + Planet.compStr = r'Undifferentiated' + + elif Planet.Do.PARTIAL_DIFFERENTIATION: + saveLabel += f'PartialDiff_qSurf{Planet.Bulk.qSurf_Wm2*1e3:.1f}mWm2' + setStr = f'$q_\mathrm{{surf}}\,{Planet.Bulk.qSurf_Wm2*FigLbl.qMult:.1f}\,\si{{{FigLbl.fluxUnits}}}$' + label += setStr - else: - if Planet.Do.ICEIh_THICKNESS: - saveLabelAppendage = f'zb{Planet.Bulk.zb_approximate_km}km' - labelAppendage = f'$z_b\,\SI{{{Planet.Bulk.zb_approximate_km}}}{{\kilo\meter}}$' - else: - saveLabelAppendage = f'Tb{Planet.Bulk.Tb_K}K' - labelAppendage = f'$T_b\,\SI{{{Planet.Bulk.Tb_K}}}{{K}}$' - if Planet.Ocean.comp == 'PureH2O': - saveLabel += f'{Planet.Ocean.comp}_{saveLabelAppendage}' - setStr = f'Pure \ce{{H2O}}' - label += f'{setStr}, {labelAppendage}' - Planet.compStr = r'Pure~\ce{H2O}' - elif "CustomSolution" in Planet.Ocean.comp: - # Get text to left of = sign - CustomSolutionLabel = Planet.Ocean.comp.split('=')[0].strip() - # Get label for plotting - setStr = CustomSolutionLabel.replace("CustomSolution", "") - # Strip latex formatting for save label - saveCustomSolutionLabel = strip_latex_formatting_from_CustomSolutionLabel(CustomSolutionLabel) - comp = CustomSolutionLabel - # In this case, we are using input speciation from user with no input w_ppt, so we will generate filenames without w_ppt - if not Planet.Do.USE_WOCEAN_PPT: - saveLabel += f'{saveCustomSolutionLabel}_{saveLabelAppendage}' - #label = f'{setStr}, {labelAppendage}' - label = f'{setStr}' - Planet.compStr = f'{CustomSolutionLabel}' - else: - saveLabel += f'{saveCustomSolutionLabel}_{Planet.Ocean.wOcean_ppt:.1f}ppt' + \ - f'_{saveLabelAppendage}' - label = f'{Planet.Ocean.comp}_{Planet.Ocean.wOcean_ppt:.1f}ppt' + \ - f'_{labelAppendage}' - Planet.compStr = f'${Planet.Ocean.wOcean_ppt*FigLbl.wMult:.1f}\,\si{{{FigLbl.wUnits}}}${CustomSolutionLabel}' - else: - saveLabel += f'{Planet.Ocean.comp}_{Planet.Ocean.wOcean_ppt:.1f}ppt' + \ - f'_{saveLabelAppendage}' - setStr = f'${Planet.Ocean.wOcean_ppt*FigLbl.wMult:.1f}\,\si{{{FigLbl.wUnits}}}$ \ce{{{Planet.Ocean.comp}}}' - label += setStr + \ - f', {labelAppendage}' - Planet.compStr = f'${Planet.Ocean.wOcean_ppt*FigLbl.wMult:.1f}\,\si{{{FigLbl.wUnits}}}$~\ce{{{Planet.Ocean.comp}}}' - if Planet.Do.CLATHRATE: - if Planet.Do.MIXED_CLATHRATE_ICE: - saveLabel += '_MixedClathrates' - label += ' w/mixed clathrates' - else: - saveLabel += '_Clathrates' - label += ' w/clath' - if Planet.Do.POROUS_ICE: - if Planet.Do.CLATHRATE and Planet.Bulk.clathType != 'bottom': - saveLabel += f'_PorousIce_phi{Planet.Ocean.phiMax_frac["Clath"]:.2f}_Pc{Planet.Ocean.Pclosure_MPa["Clath"]:5.2e}' - else: - saveLabel += f'_PorousIce_phi{Planet.Ocean.phiMax_frac["Ih"]:.2f}_Pc{Planet.Ocean.Pclosure_MPa["Ih"]:5.2e}' - label += ' w/$\phi_\mathrm{ice}$' - if Planet.Do.PORE_EOS_DIFFERENT: if Planet.Sil.poreComp == 'PureH2O': + Planet.compStr = r'Pure~\ce{H2O}' saveLabel += f'_{Planet.Sil.poreComp}Pores' - label += f'Pure \ce{{H2O}} pores' else: + Planet.compStr = f'${Planet.Sil.wPore_ppt*FigLbl.wMult:.1f}\,\si{{{FigLbl.wUnits}}}$~\ce{{{Planet.Sil.poreComp}}}' saveLabel += f'_{Planet.Sil.poreComp}_{Planet.Sil.wPore_ppt:.1f}pptPores' - label += f', {Planet.Sil.wPore_ppt*FigLbl.wMult:.1f}\,\si{{{FigLbl.wUnits}}} \ce{{{Planet.Sil.poreComp}}} pores' - elif Planet.Do.POROUS_ROCK: + saveLabel += f'_PorousRock_phi{Planet.Sil.phiRockMax_frac:.2f}_Pc{Planet.Sil.Pclosure_MPa:5.2e}' label += ' w/$\phi_\mathrm{sil}$' - if Planet.Do.HYDROSPHERE_THICKNESS: - saveLabel += f'_{Planet.Bulk.Dhsphere_m}' - label += f'$hydroThickness\,\SI{{{Planet.Bulk.Dhsphere_m}}}{{\meter}}$' - if Planet.Sil.mantleEOSName is not None: saveLabel += f'_{Planet.Sil.mantleEOSName}' + + else: + if Planet.Do.ICEIh_THICKNESS: + saveLabelAppendage = f'zb{Planet.Bulk.zb_approximate_km}km' + labelAppendage = f'$z_b\,\SI{{{Planet.Bulk.zb_approximate_km}}}{{\kilo\meter}}$' + else: + saveLabelAppendage = f'Tb{Planet.Bulk.Tb_K}K' + labelAppendage = f'$T_b\,\SI{{{Planet.Bulk.Tb_K}}}{{K}}$' + if Planet.Ocean.comp == 'PureH2O': + saveLabel += f'{Planet.Ocean.comp}_{saveLabelAppendage}' + setStr = f'Pure \ce{{H2O}}' + label += f'{setStr}, {labelAppendage}' + Planet.compStr = r'Pure~\ce{H2O}' + elif "CustomSolution" in Planet.Ocean.comp: + # Get text to left of = sign + CustomSolutionLabel = Planet.Ocean.comp.split('=')[0].strip() + # Get label for plotting + setStr = CustomSolutionLabel.replace("CustomSolution", "") + # Strip latex formatting for save label + saveCustomSolutionLabel = strip_latex_formatting_from_CustomSolutionLabel(CustomSolutionLabel) + comp = CustomSolutionLabel + # In this case, we are using input speciation from user with no input w_ppt, so we will generate filenames without w_ppt + if not Planet.Do.USE_WOCEAN_PPT: + saveLabel += f'{saveCustomSolutionLabel}_{saveLabelAppendage}' + #label = f'{setStr}, {labelAppendage}' + label = f'{setStr}' + Planet.compStr = f'{CustomSolutionLabel}' + else: + saveLabel += f'{saveCustomSolutionLabel}_{Planet.Ocean.wOcean_ppt:.1f}ppt' + \ + f'_{saveLabelAppendage}' + label = f'{setStr}_{Planet.Ocean.wOcean_ppt:.1f}ppt' + \ + f'_{labelAppendage}' + Planet.compStr = f'${Planet.Ocean.wOcean_ppt*FigLbl.wMult:.1f}\,\si{{{FigLbl.wUnits}}}${CustomSolutionLabel}' + else: + saveLabel += f'{Planet.Ocean.comp}_{Planet.Ocean.wOcean_ppt:.1f}ppt' + \ + f'_{saveLabelAppendage}' + setStr = f'${Planet.Ocean.wOcean_ppt*FigLbl.wMult:.1f}\,\si{{{FigLbl.wUnits}}}$ \ce{{{Planet.Ocean.comp}}}' + label += setStr + \ + f', {labelAppendage}' + Planet.compStr = f'${Planet.Ocean.wOcean_ppt*FigLbl.wMult:.1f}\,\si{{{FigLbl.wUnits}}}$~\ce{{{Planet.Ocean.comp}}}' + if Planet.Do.CLATHRATE: + if Planet.Do.MIXED_CLATHRATE_ICE: + saveLabel += '_MixedClathrates' + label += ' w/mixed clathrates' + else: + saveLabel += '_Clathrates' + label += ' w/clath' + if Planet.Do.POROUS_ICE: + if Planet.Do.CLATHRATE and Planet.Bulk.clathType != 'bottom': + saveLabel += f'_PorousIce_phi{Planet.Ocean.phiMax_frac["Clath"]:.2f}_Pc{Planet.Ocean.Pclosure_MPa["Clath"]:5.2e}' + else: + saveLabel += f'_PorousIce_phi{Planet.Ocean.phiMax_frac["Ih"]:.2f}_Pc{Planet.Ocean.Pclosure_MPa["Ih"]:5.2e}' + label += ' w/$\phi_\mathrm{ice}$' + if Planet.Do.PORE_EOS_DIFFERENT: + if Planet.Sil.poreComp == 'PureH2O': + saveLabel += f'_{Planet.Sil.poreComp}Pores' + label += f'Pure \ce{{H2O}} pores' + else: + saveLabel += f'_{Planet.Sil.poreComp}_{Planet.Sil.wPore_ppt:.1f}pptPores' + label += f', {Planet.Sil.wPore_ppt*FigLbl.wMult:.1f}\,\si{{{FigLbl.wUnits}}} \ce{{{Planet.Sil.poreComp}}} pores' + elif Planet.Do.POROUS_ROCK: + saveLabel += f'_PorousRock_phi{Planet.Sil.phiRockMax_frac:.2f}_Pc{Planet.Sil.Pclosure_MPa:5.2e}' + label += ' w/$\phi_\mathrm{sil}$' + if Planet.Do.HYDROSPHERE_THICKNESS: + saveLabel += f'_{Planet.Bulk.Dhsphere_m}' + label += f'$hydroThickness\,\SI{{{Planet.Bulk.Dhsphere_m}}}{{\meter}}$' + if Planet.Sil.mantleEOSName is not None: saveLabel += f'_{Planet.Sil.mantleEOSName}' + if Planet.Do.CONSTANT_INNER_DENSITY: + if Planet.Do.Fe_CORE: + saveLabel += f'_ConstantInnerRho_Fe{Planet.Core.rhoFe_kgm3}_FeS{Planet.Core.rhoFeS_kgm3}_xFeS{Planet.Core.xFeS:.3f}_Sil{Planet.Sil.rhoSilWithCore_kgm3}kgm3' + else: + saveLabel += f'_ConstantInnerRho' # Add time and date label if Params.TIME_AND_DATE_LABEL: @@ -622,20 +681,27 @@ def SetupFilenames(Planet, Params, exploreAppend=None, figExploreAppend=None): Params.Induct.SetFlabel(Planet.bodyname) inductAppend = Params.Induct.fLabel exploreBase = None + monteCarloBase = None else: inductBase = None inductAppend = None if Params.DO_EXPLOREOGRAM: exploreBase = f'{Planet.name}ExploreOgram_{exploreAppend}_{saveLabel}' + monteCarloBase = None else: exploreBase = None + if Params.DO_MONTECARLO: + monteCarloBase = f'{Planet.name}MonteCarlo_{monteCarloAppend}_{saveLabel}' + else: + monteCarloBase = None + DataFiles = DataFilesSubstruct(datPath, saveBase + saveLabel, comp, inductBase=inductBase, exploreAppend=exploreAppend, EXPLORE=(Params.DO_INDUCTOGRAM or - Params.DO_EXPLOREOGRAM or Params.INDUCTOGRAM_IN_PROGRESS), - inductAppend=inductAppend) + Params.DO_EXPLOREOGRAM or Params.DO_MONTECARLO or Params.INDUCTOGRAM_IN_PROGRESS), + inductAppend=inductAppend, monteCarloAppend=monteCarloAppend) FigureFiles = FigureFilesSubstruct(figPath, saveBase + saveLabel, FigMisc.xtn, - comp=comp, exploreBase=exploreBase, inductBase=inductBase, - exploreAppend=figExploreAppend, inductAppend=inductAppend) + comp=comp, exploreBase=exploreBase, inductBase=inductBase, monteCarloBase=monteCarloBase, + exploreAppend=figExploreAppend, inductAppend=inductAppend, monteCarloAppend=monteCarloAppend, inputBaseOverride = Params.OverrideFigureBase) # Attach profile name to PlanetStruct in addition to Params Planet.saveFile = DataFiles.saveFile + '' @@ -646,17 +712,21 @@ def SetupFilenames(Planet, Params, exploreAppend=None, figExploreAppend=None): def SetupLayers(Planet): """ Initialize layer arrays in Planet. """ - - if not Planet.Do.NO_H2O: - nOceanMax = int(Planet.Ocean.PHydroMax_MPa / Planet.Ocean.deltaP) - Planet.Steps.nHydroMax = Planet.Steps.nClath + Planet.Steps.nIceI + Planet.Steps.nIceIIILitho + Planet.Steps.nIceVLitho + nOceanMax + if not Planet.Do.NON_SELF_CONSISTENT: + if not Planet.Do.NO_H2O: + nOceanMax = int(Planet.Ocean.PHydroMax_MPa / Planet.Ocean.deltaP) + Planet.Steps.nHydroMax = Planet.Steps.nClath + Planet.Steps.nIceI + Planet.Steps.nIceIIILitho + Planet.Steps.nIceVLitho + nOceanMax + nStepsForArrays = Planet.Steps.nHydroMax + else: + """ For non-self-consistent modeling, we use nTotal rather than hydromax, since we are setting layers for non-self consistent modeling""" + nStepsForArrays = Planet.Steps.nTotal - Planet.phase = np.zeros(Planet.Steps.nHydroMax, dtype=np.int_) + Planet.phase = np.zeros(nStepsForArrays, dtype=np.int_) Planet.P_MPa, Planet.T_K, Planet.r_m, Planet.rho_kgm3, \ Planet.Cp_JkgK, Planet.alpha_pK, Planet.g_ms2, Planet.phi_frac, \ Planet.sigma_Sm, Planet.z_m, Planet.MLayer_kg, Planet.VLayer_m3, Planet.kTherm_WmK, \ Planet.Htidal_Wm3, Planet.Ppore_MPa, Planet.rhoMatrix_kgm3, Planet.rhoPore_kgm3 = \ - (np.zeros(Planet.Steps.nHydroMax) for _ in range(17)) + (np.zeros(nStepsForArrays) for _ in range(17)) # Layer property initialization for surface Planet.z_m[0] = 0.0 # Set first layer depth to zero (layer properties correspond to outer radius) @@ -696,3 +766,449 @@ def SetCMR2strings(Planet): Planet.CMR2str5 = f'{Planet.Bulk.Cmeasured:.5f}^{{+{Planet.Bulk.CuncertaintyUpper:.5f}}}_{{-{Planet.Bulk.CuncertaintyLower:.5f}}}' return Planet + + +def SetupNonSelfConsistent(Planet, Params): + """ Initialize non-self-consistent layer arrays in Planet. + """ # Verify that all depths have been specified + # For now we must disable writing since code isn't properly setting all output values + Params.NO_SAVEFILE = True + Params.PLOT_BDIP = False + Params.CALC_NEW_GRAVITY = False + + if Planet.Do.NON_SELF_CONSISTENT: + Planet.zb_km = 0 + if Planet.dzIceI_km == np.nan or Planet.dzIceI_km < 0: + raise ValueError('Planet.dzIceI_km must be set to non-negative thickness for non-self-consistent ice modeling.') + elif Planet.dzIceI_km == 0: + Planet.Steps.nIceI = 0 + else: + Planet.zb_km += Planet.dzIceI_km + # Check that ice shell density is set, otherwise use default + if Planet.Ocean.rhoCondMean_kgm3['Ih'] is None: + Planet.Ocean.rhoCondMean_kgm3['Ih'] = Constants.STP_kgm3['Ih'] + log.warning('Planet.Sil.rhoCondMean_kgm3 is not set, using Constants.STP_kgm3["Ih"].') + else: + Planet.Do.CONSTANTPROPSEOS = True + + # Set up melting point viscosity + if Planet.etaMelt_Pas is None: + Planet.etaMelt_Pas = Constants.etaMelt_Pas[1] + log.warning('Planet.Sil.etaMelt_Pas is not set, using Constants.etaMelt_Pas[1].') + else: + Planet.Do.CONSTANTPROPSEOS = True + + # Set up thermal conductivity + if Planet.Ocean.kThermIce_WmK['Ih'] is None: + Planet.Ocean.kThermIce_WmK['Ih'] = Constants.kThermIce_WmK['Ih'] + log.warning('Planet.Sil.kThermIce_WmK is not set, using Constants.kThermIce_WmK["Ih"].') + else: + Planet.Do.CONSTANTPROPSEOS = True + + # Set up creep parameters + if Planet.Ocean.Eact_kJmol['Ih'] is None: + Planet.Ocean.Eact_kJmol['Ih'] = Constants.Eact_kJmol[1] + log.warning('Planet.Sil.Eact_kJmol is not set, using Constants.Eact_kJmol[1].') + else: + Planet.Do.CONSTANTPROPSEOS = True + + # Set up shear modulus in conducting layer + if Planet.Ocean.GScondMean_GPa['Ih'] is None: + Planet.Ocean.GScondMean_GPa['Ih'] = Constants.GS_GPa[1] + log.warning('Planet.Sil.GScondMean_GPa is not set, using Constants.GS_GPa[1].') + else: + Planet.Do.CONSTANTPROPSEOS = True + + if Planet.Do.CONSTANTPROPSEOS: + Planet.Ocean.constantProperties['Ih'] = {'rho_kgm3': Planet.Ocean.rhoCondMean_kgm3['Ih'], + 'Cp_JkgK': Constants.Cp_JkgK['Ih'], + 'alpha_pK': Constants.alphaIce_pK['Ih'], + 'kTherm_WmK': Planet.Ocean.kThermIce_WmK['Ih'], + 'VP_GPa': Constants.VP_GPa[1], + 'VS_GPa': Constants.VS_GPa[1], + 'KS_GPa': Constants.KS_GPa[1], + 'GS_GPa': Planet.Ocean.GScondMean_GPa['Ih'], + 'sigma_Sm': Planet.Ocean.sigmaIce_Sm['Ih'], + 'eta_Pas': Constants.etaIce_Pas[0]} + + if Planet.Do.BOTTOM_ICEIII: + if (Planet.dzIceIII_km == np.nan or Planet.dzIceIII_km < 0): + raise ValueError('Planet.Do.BOTTOM_ICEIII is True, but Planet.dzIceIII_km is not set or is negative.') + else: + Planet.zb_km += Planet.dzIceIII_km + # Check that ice shell density is set, otherwise use default + if Planet.Ocean.rhoCondMean_kgm3['III'] is None: + Planet.Ocean.rhoCondMean_kgm3['III'] = Constants.STP_kgm3['III'] + log.warning('Planet.Sil.rhoCondMean_kgm3 is not set, using Constants.STP_kgm3["III"].') + else: + Planet.Do.CONSTANTPROPSEOS = True + + # Set up melting point viscosity + if Planet.etaMeltIII_Pas is None: + Planet.etaMeltIII_Pas = Constants.etaMelt_Pas[3] + log.warning('Planet.Sil.etaMeltIII_Pas is not set, using Constants.etaMelt_Pas[3].') + else: + Planet.Do.CONSTANTPROPSEOS = True + + # Set up thermal conductivity + if Planet.Ocean.kThermIce_WmK['III'] is None: + Planet.Ocean.kThermIce_WmK['III'] = Constants.kThermIce_WmK['III'] + log.warning('Planet.Sil.kThermIce_WmK is not set, using Constants.kThermIce_WmK["III"].') + else: + Planet.Do.CONSTANTPROPSEOS = True + + # Set up creep parameters + if Planet.Ocean.Eact_kJmol['III'] is None: + Planet.Ocean.Eact_kJmol['III'] = Constants.Eact_kJmol[3] + log.warning('Planet.Sil.Eact_kJmol is not set, using Constants.Eact_kJmol[3].') + else: + Planet.Do.CONSTANTPROPSEOS = True + + # Set up shear modulus in conducting layer + if Planet.Ocean.GScondMean_GPa['III'] is None: + Planet.Ocean.GScondMean_GPa['III'] = Constants.GS_GPa[3] + log.warning('Planet.Sil.GScondMean_GPa is not set, using Constants.GS_GPa[3].') + else: + Planet.Do.CONSTANTPROPSEOS = True + + if Planet.Do.CONSTANTPROPSEOS: + Planet.Ocean.constantProperties['III'] = {'rho_kgm3': Planet.Ocean.rhoCondMean_kgm3['III'], + 'Cp_JkgK': Constants.Cp_JkgK['III'], + 'alpha_pK': Constants.alphaIce_pK['III'], + 'kTherm_WmK': Planet.Ocean.kThermIce_WmK['III'], + 'VP_GPa': Constants.VP_GPa[3], + 'VS_GPa': Constants.VS_GPa[3], + 'KS_GPa': Constants.KS_GPa[3], + 'GS_GPa': Planet.Ocean.GScondMean_GPa['III'], + 'sigma_Sm': Planet.Ocean.sigmaIce_Sm['III'], + } + else: + Planet.Steps.nIceIIILitho = 0 + Planet.Steps.nIceVLitho = 0 + if Planet.Do.BOTTOM_ICEV: + if (Planet.dzIceV_km == np.nan or Planet.dzIceV_km < 0): + raise ValueError('Planet.Do.BOTTOM_ICEV is True, but Planet.dzIceV_km is not set or is negative.') + else: + Planet.zb_km += Planet.dzIceV_km + # Check that ice shell density is set, otherwise use default + if Planet.Ocean.rhoCondMean_kgm3['V'] is None: + Planet.Ocean.rhoCondMean_kgm3['V'] = Constants.STP_kgm3['V'] + log.warning('Planet.Sil.rhoCondMean_kgm3 is not set, using Constants.STP_kgm3["V"].') + else: + Planet.Do.CONSTANTPROPSEOS = True + + # Set up melting point viscosity + if Planet.etaMeltV_Pas is None: + Planet.etaMeltV_Pas = Constants.etaMelt_Pas[5] + log.warning('Planet.Sil.etaMeltV_Pas is not set, using Constants.etaMelt_Pas[5].') + else: + Planet.Do.CONSTANTPROPSEOS = True + + # Set up thermal conductivity + if Planet.Ocean.kThermIce_WmK['V'] is None: + Planet.Ocean.kThermIce_WmK['V'] = Constants.kThermIce_WmK['V'] + log.warning('Planet.Sil.kThermIce_WmK is not set, using Constants.kThermIce_WmK["V"].') + else: + Planet.Do.CONSTANTPROPSEOS = True + + # Set up creep parameters + if Planet.Ocean.Eact_kJmol['V'] is None: + Planet.Ocean.Eact_kJmol['V'] = Constants.Eact_kJmol[5] + log.warning('Planet.Sil.Eact_kJmol is not set, using Constants.Eact_kJmol[5].') + else: + Planet.Do.CONSTANTPROPSEOS = True + + # Set up shear modulus in conducting layer + if Planet.Ocean.GScondMean_GPa['V'] is None: + Planet.Ocean.GScondMean_GPa['V'] = Constants.GS_GPa[5] + log.warning('Planet.Sil.GScondMean_GPa is not set, using Constants.GS_GPa[5].') + else: + Planet.Do.CONSTANTPROPSEOS = True + + if Planet.Do.CONSTANTPROPSEOS: + Planet.Ocean.constantProperties['V'] = {'rho_kgm3': Planet.Ocean.rhoCondMean_kgm3['V'], + 'Cp_JkgK': Constants.Cp_JkgK['V'], + 'alpha_pK': Constants.alphaIce_pK['V'], + 'kTherm_WmK': Planet.Ocean.kThermIce_WmK['V'], + 'VP_GPa': Constants.VP_GPa[5], + 'VS_GPa': Constants.VS_GPa[5], + 'KS_GPa': Constants.KS_GPa[5], + 'GS_GPa': Planet.Ocean.GScondMean_GPa['V'], + 'sigma_Sm': Planet.Ocean.sigmaIce_Sm['V'], + } + else: + Planet.Steps.nIceVLitho = 0 + if Planet.Do.CLATHRATE: + Planet.Steps.nClath = 0 + else: + Planet.Steps.nClath = 0 + Planet.Steps.nSurfIce = Planet.Steps.nIceI+Planet.Steps.nIceIIILitho+Planet.Steps.nIceVLitho+Planet.Steps.nClath + if Planet.D_km is None or Planet.D_km < 0: + raise ValueError('Planet.D_km must be set to non-negative thickness for non-self-consistent ocean modeling.') + elif Planet.D_km == 0: + Planet.Do.NO_OCEAN = True + Planet.Steps.nOcean = 0 + else: + # Set up ocean density, if specified + if Planet.Ocean.rhoMean_kgm3 is None: + Planet.Ocean.rhoMean_kgm3 = Constants.STP_kgm3['0'] + log.warning('Planet.Ocean.rhoMean_kgm3 is not set, using Constants.STP_kgm3["0"].') + else: + Planet.Do.CONSTANTPROPSEOS = True + + # Set up thermal conductivity, if specified + if Planet.Ocean.kThermWater_WmK is None: + Planet.Ocean.kThermOcean_WmK = Constants.kThermWater_WmK + log.warning('Planet.Ocean.kThermWater_WmK is not set, using Constants.kThermWater_WmK.') + else: + Planet.Do.CONSTANTPROPSEOS = True + + # Set up electrical condonctvity, if specified + if Planet.Ocean.sigmaFixed_Sm is None: + Planet.Ocean.sigmaFixed_Sm = Constants.sigmaH2O_Sm + log.warning('Planet.Ocean.sigmaFixed_Sm is not set, using Constants.sigmaH2O_Sm.') + else: + Planet.Do.CONSTANTPROPSEOS = True + + if Planet.Do.CONSTANTPROPSEOS: + Planet.Ocean.oceanConstantProperties = {'rho_kgm3': Planet.Ocean.rhoMean_kgm3, + 'Cp_JkgK': Constants.CpWater_JkgK, + 'alpha_pK': Constants.alphaWater_pK, + 'kTherm_WmK': Planet.Ocean.kThermWater_WmK, + 'VP_kms': Constants.VPOcean_kms, + 'VS_kms': Constants.VSOcean_kms, + 'sigma_Sm': Planet.Ocean.sigmaFixed_Sm, + 'eta_Pas': Constants.etaH2O_Pas, + } + + # Setup inner properties #TODO + if Planet.Core.etaFeSolid_Pas is None: + Planet.Core.etaFeSolid_Pas = Constants.etaFeSolid_Pas + if Planet.Core.etaFeLiquid_Pas is None: + Planet.Core.etaFeLiquid_Pas = Constants.etaFeLiquid_Pas + + Planet.Steps.nHydro = Planet.Steps.nSurfIce + Planet.Steps.nOcean + if Planet.Core.Rmean_m is None or Planet.Core.Rmean_m < 0: + raise ValueError('Planet.Core.Rmean_m must be set to non-negative radius for non-self-consistent inner modeling.') + elif Planet.Core.Rmean_m == 0: + Planet.Do.Fe_CORE = False + Planet.Steps.nCore = 0 + Planet.Sil.mantleEOS = 'none' + + # Check if we have specified a valid ocean composition + if Planet.Ocean.comp is None: + Planet.Ocean.comp = 'none' + Planet.Ocean.wOcean_ppt = 0.0 + Planet.Sil.PHydroMax_MPa = Planet.Bulk.Psurf_MPa + else: + # If so, then we will use the ocean composition to query bulk temperature andEC + pass + + Planet.Steps.nTotal = Planet.Steps.nHydro + Planet.Steps.nSil + Planet.Steps.nCore + + # Finally set parameters that are not used in non-self-consistent modeling to be consistent with the self-consistent setup + Planet.Steps.nHydroMax = Planet.Steps.nHydro + Planet.Steps.nOceanMax = Planet.Steps.nOcean + Planet.Steps.nSilMax = Planet.Steps.nSil + Planet.Steps.nCoreMax = Planet.Steps.nCore + return Planet + + +def PrecomputeEOS(PlanetList, Params): + """ Pre-generate all EOS objects needed for parallel processing of the grid. + This avoids the overhead of recreating EOS objects in each worker process. + + Args: + PlanetGrid (ndarray): Grid of Planet objects to be processed + Params (ParamsStruct): Parameters for the run + + Returns: + None (EOS objects are stored in global EOSlist.loaded) + """ + log.info('Pre-generating EOS objects for parallel processing...') + Params = deepcopy(Params) + PlanetList = deepcopy(PlanetList) + Params.PRELOAD_EOS_IN_PROGRESS = True + # Collect all unique EOS configurations needed across the grid + oceanPlanets = [] + innerPlanetEOSlabels = set() + innerPlanets = [] + Planet = PlanetList.flatten()[0] + Planet, _ = SetupInit(Planet, Params) + # Determine the maximum P,T ranges needed across all models + maxPmelt_MPa = Planet.PfreezeUpper_MPa + maxTmelt_K = Planet.TfreezeUpper_K + minPmelt_MPa = Planet.PfreezeLower_MPa + minTmelt_K = Planet.TfreezeLower_K + deltaPmelt = Planet.PfreezeRes_MPa + deltaTmelt = Planet.TfreezeRes_K + maxPOcean_MPa = Planet.Ocean.PHydroMax_MPa + maxTOcean_K = Planet.Ocean.THydroMax_K + minPOcean_MPa = Planet.Bulk.Psurf_MPa + minTOcean_K = Planet.TfreezeLower_K + deltaPOcean = Planet.Ocean.deltaP + deltaTOcean = Planet.Ocean.deltaT + + minPSurfIce_MPa = Planet.Bulk.Psurf_MPa + minTSurfIce_K = Planet.Bulk.Tsurf_K + maxPHPIce_MPa = maxPOcean_MPa + maxPHPIceT_K = maxTOcean_K + if Params.minPres_MPa is not None: + deltaPIce = Params.minPres_MPa + log.profile(f'Setting deltaPIce to {deltaPIce} based on Params.minPres_MPa.') + else: + deltaPIce = Planet.Ocean.deltaP + Params.minPres_MPa = deltaPIce + log.profile(f'Setting deltaPIce to {deltaPIce} based on Planet.Ocean.deltaP because Params.minPres_MPa is not set. This might be too big for the ice EOS propogation based on the number of input ice steps, considering setting Params.minPres_MPa.') + + if Params.minTres_K is not None: + deltaTIce = Params.minTres_K + log.profile(f'Setting deltaTIce to {deltaTIce} based on Params.minTres_K.') + else: + deltaTIce = Planet.Ocean.deltaT + Params.minTres_K = deltaTIce + log.profile(f'Setting deltaTIce to {deltaTIce} based on Planet.Ocean.deltaT because Params.minTres_K is not set. This might be too big for the ice EOS propogation based on the number of input ice steps, considering setting Params.minTres_K.') + + GetIceEOSTracker = False + DoConvectionTracker = False + for i, Planet in enumerate(PlanetList.flatten()): + Planet, _ = SetupInit(Planet, Params) + if not Planet.Do.NO_H2O: + if not Planet.Do.NO_OCEAN: + oceanPlanets.append(Planet) + minPmelt_MPa = min(minPmelt_MPa, Planet.PfreezeLower_MPa) + maxPmelt_MPa = max(maxPmelt_MPa, Planet.PfreezeUpper_MPa) + minTmelt_K = min(minTmelt_K, Planet.TfreezeLower_K) + maxTmelt_K = max(maxTmelt_K, Planet.TfreezeUpper_K) + deltaPmelt = min(deltaPmelt, Planet.PfreezeRes_MPa) + deltaTmelt = min(deltaTmelt, Planet.TfreezeRes_K) + + minPOcean_MPa = min(minPOcean_MPa, Planet.PfreezeLower_MPa) + maxPOcean_MPa = max(maxPOcean_MPa, Planet.Ocean.PHydroMax_MPa) + maxTOcean_K = max(maxTOcean_K, Planet.Ocean.THydroMax_K) + minTOcean_K = min(minTOcean_K, Planet.TfreezeLower_K if Planet.Do.ICEIh_THICKNESS else Planet.Bulk.Tb_K) + deltaPOcean = min(deltaPOcean, Planet.Ocean.deltaP) + deltaTOcean = min(deltaTOcean, Planet.Ocean.deltaT) + if not Planet.Do.NO_ICE_CONVECTION: + DoConvectionTracker = True + GetIceEOSTracker = True + minPSurfIce_MPa = min(minPSurfIce_MPa, Planet.Bulk.Psurf_MPa) + minTSurfIce_K = min(minTSurfIce_K, Planet.Bulk.Tsurf_K) + if Planet.Do.POROUS_ROCK or Planet.Do.NO_DIFFERENTIATION or Planet.Do.PARTIAL_DIFFERENTIATION: + maxPHPIce_MPa = max(maxPHPIce_MPa, Planet.Sil.PHydroMax_MPa) + maxPHPIceT_K = max(maxPHPIceT_K, Planet.Sil.THydroMax_K) + if Planet.Do.POROUS_ROCK: + maxTOcean_K = max(maxTOcean_K, Planet.Sil.THydroMax_K) + maxPOcean_MPa = max(maxPOcean_MPa, Planet.Sil.PHydroMax_MPa) + + + # Inner EOS configurations + if not Params.SKIP_INNER: + innerPlanets.append(Planet) + if len(oceanPlanets) > 0: + if np.any(np.array(['CustomSolution' in planet.Ocean.comp for planet in oceanPlanets])): + if Params.DO_PARALLEL: + msg = f' Will use parallel processing.' + else: + msg = f' Will use serial processing since Params.DO_PARALLEL is False. If the CustomSolution ocean EOS has not been previously calculated with Reaktoro, this may take a while.' + + # To reduce run-time, we will get only the unique CustomSolution ocean compositions + uniqueEOSCustomSolutions = [] + uniqueEOSlabels = set() + for oceanPlanet in oceanPlanets: + if 'CustomSolution' in oceanPlanet.Ocean.comp: + EOSlabel = GetOceanEOSLabel(compstr=oceanPlanet.Ocean.comp, wOcean_ppt=oceanPlanet.Ocean.wOcean_ppt, elecType=oceanPlanet.Ocean.MgSO4elecType, rhoType=oceanPlanet.Ocean.MgSO4rhoType, + scalingType=oceanPlanet.Ocean.MgSO4scalingType, phaseType=oceanPlanet.Ocean.phaseType, EXTRAP=Params.EXTRAP_OCEAN, + PORE=oceanPlanet.Do.OCEAN_PHASE_HIRES, LOOKUP_HIRES=oceanPlanet.Do.OCEAN_PHASE_HIRES, etaFixed_Pas=oceanPlanet.Ocean.kThermWater_WmK, + meltStr='', propsStepReductionFactor=oceanPlanet.Ocean.propsStepReductionFactor) + if EOSlabel not in uniqueEOSlabels: + uniqueEOSlabels.add(EOSlabel) + uniqueEOSCustomSolutions.append((oceanPlanet.Ocean.comp, oceanPlanet.Ocean.wOcean_ppt)) + log.profile(f'Pre-generating CustomSolution ocean EOS for {len(uniqueEOSCustomSolutions)} CustomSolution compositions. {msg}') + if Params.DO_PARALLEL: + # Prevent slowdowns from competing process spawning when #cores > #jobs + nCores = np.min([Params.maxCores, np.prod(np.shape(uniqueEOSCustomSolutions)), Params.threadLimit]) + pool = mtpContext.Pool(nCores) + parResult = [pool.apply_async(SetupCustomSolutionEOS, (deepcopy(comp), deepcopy(wOcean_ppt))) for comp, wOcean_ppt in uniqueEOSCustomSolutions] + pool.close() + pool.join() + else: + for comp, wOcean_ppt in uniqueEOSCustomSolutions: + SetupCustomSolutionEOS(comp, wOcean_ppt) + + TOcean_K = np.arange(minTOcean_K, maxTOcean_K, deltaTOcean, dtype=np.float32) + POcean_MPa = np.arange(minPOcean_MPa, maxPOcean_MPa, deltaPOcean, dtype=np.float32) + Pmelt_MPa = np.arange(minPmelt_MPa, maxPmelt_MPa, deltaPmelt, dtype=np.float32) + Tmelt_K = np.arange(minTmelt_K, maxTmelt_K, deltaTmelt, dtype=np.float32) + for i, oceanPlanet in enumerate(oceanPlanets): + GetOceanEOS(oceanPlanet.Ocean.comp, oceanPlanet.Ocean.wOcean_ppt, POcean_MPa, TOcean_K, oceanPlanet.Ocean.MgSO4elecType, rhoType = oceanPlanet.Ocean.MgSO4rhoType, scalingType = oceanPlanet.Ocean.MgSO4scalingType, phaseType = oceanPlanet.Ocean.phaseType, EXTRAP = Params.EXTRAP_OCEAN, LOOKUP_HIRES = oceanPlanet.Do.OCEAN_PHASE_HIRES, + etaFixed_Pas = oceanPlanet.Ocean.kThermWater_WmK, doConstantProps=oceanPlanet.Do.CONSTANTPROPSEOS, constantProperties=oceanPlanet.Ocean.oceanConstantProperties, propsStepReductionFactor=oceanPlanet.Ocean.propsStepReductionFactor) + GetOceanEOS(oceanPlanet.Ocean.comp, oceanPlanet.Ocean.wOcean_ppt, Pmelt_MPa, Tmelt_K, elecType=None, + phaseType=oceanPlanet.Ocean.phaseType, MELT=True, + LOOKUP_HIRES=oceanPlanet.Do.OCEAN_PHASE_HIRES, propsStepReductionFactor=1) + log.profile(f'Ocean EOS {i+1} of {len(oceanPlanets)} pre-generated.') + + if DoConvectionTracker: + log.profile(f'Pre-generating Pure H2O ocean EOS for convection.') + Pmelt_MPa = np.arange(minPmelt_MPa, maxPmelt_MPa, .05, dtype=np.float32) + Tmelt_K = np.arange(240,280, .05, dtype=np.float32) + GetOceanEOS('PureH2O', 0.0, Pmelt_MPa, Tmelt_K, elecType=None, + phaseType='calc', MELT=True) + log.profile(f'Pure H2O ocean EOS for convection pre-generated.') + + if GetIceEOSTracker: + icePhases = ['Ih', 'II', 'III', 'V', 'VI'] + log.profile(f'Pre-generating ice EOS for {len(icePhases)} ice phases.') + PSurfIce_MPa = np.arange(minPSurfIce_MPa, maxPmelt_MPa, deltaPIce, dtype=np.float32) + TSurfIce_K = np.arange(minTSurfIce_K, maxTmelt_K, deltaTIce, dtype=np.float32) + for i, icePhase in enumerate(icePhases): + if icePhase != 'Ih' and maxPmelt_MPa < Constants.PminHPices_MPa: + pass + else: + GetIceEOS(PSurfIce_MPa, TSurfIce_K, icePhase, EXTRAP=Params.EXTRAP_ICE[icePhase], + ICEIh_DIFFERENT=Planet.Do.ICEIh_DIFFERENT, kThermConst_WmK=Planet.Ocean.kThermIce_WmK, + mixParameters={'mixFrac': Planet.Bulk.volumeFractionClathrate, 'JmixedRheologyConstant': Planet.Bulk.JmixedRheologyConstant}, + doConstantProps=Planet.Do.CONSTANTPROPSEOS, + constantProperties=Planet.Ocean.constantProperties[icePhase], + minPres_MPa=Params.minPres_MPa, minTres_K=Params.minTres_K) + log.profile(f'Surfice {icePhase} EOS pre-generated.') + + PHPIce_MPa = np.arange(minPOcean_MPa, maxPHPIce_MPa, deltaPIce) + TOceanHPices_K = np.arange(minTOcean_K, maxPHPIceT_K, deltaTIce) + for i, icePhase in enumerate(icePhases): + if icePhase == 'Ih': + pass + else: + GetIceEOS(PHPIce_MPa, TOceanHPices_K, icePhase, EXTRAP=Params.EXTRAP_ICE[icePhase], + porosType=Planet.Ocean.porosType[icePhase], + phiTop_frac=Planet.Ocean.phiMax_frac[icePhase], + Pclosure_MPa=Planet.Ocean.Pclosure_MPa[icePhase], + phiMin_frac=Planet.Ocean.phiMin_frac, + minPres_MPa=deltaPIce, minTres_K=deltaTIce, kThermConst_WmK=Planet.Ocean.kThermIce_WmK) + log.profile(f'HP {icePhase} EOS pre-generated.') + if len(innerPlanets) > 0: + log.profile(f'Pre-generating inner EOS for {len(innerPlanets)} inner planets.') + for i, innerPlanet in enumerate(innerPlanets): + Planet.Sil.GSmean_GPa = 50.0 + GetInnerEOS(Planet.Sil.mantleEOS, EOSinterpMethod=Params.lookupInterpMethod, + kThermConst_WmK=Planet.Sil.kTherm_WmK, HtidalConst_Wm3=Planet.Sil.Htidal_Wm3, + porosType=Planet.Sil.porosType, phiTop_frac=Planet.Sil.phiRockMax_frac, + Pclosure_MPa=Planet.Sil.Pclosure_MPa, phiMin_frac=Planet.Sil.phiMin_frac, + EXTRAP=Params.EXTRAP_SIL, etaSilFixed_Pas=Planet.Sil.etaRock_Pas, etaCoreFixed_Pas=[Planet.Core.etaFeSolid_Pas, Planet.Core.etaFeLiquid_Pas], + TviscTrans_K=Planet.Sil.TviscTrans_K, + doConstantProps=Planet.Do.CONSTANT_INNER_DENSITY, constantProperties={'rho_kgm3': Planet.Sil.rhoSilWithCore_kgm3, 'Cp_JkgK': np.nan, 'alpha_pK': np.nan, 'kTherm_WmK': Planet.Sil.kTherm_WmK, + 'VP_kms': Planet.Sil.VPset_kms, 'VS_kms': Planet.Sil.VSset_kms, 'KS_GPa': Planet.Sil.KSset_GPa, 'GS_GPa': Planet.Sil.GSset_GPa, 'eta_Pas': Planet.Sil.etaRock_Pas, + 'sigma_Sm': Planet.Sil.sigmaSil_Sm}) + if Planet.Do.Fe_CORE: + Planet.Core.GSmean_GPa = 50.0 + GetInnerEOS(Planet.Core.coreEOS, EOSinterpMethod=Params.lookupInterpMethod, Fe_EOS=True, + kThermConst_WmK=Planet.Core.kTherm_WmK, EXTRAP=Params.EXTRAP_Fe, + wFeCore_ppt=Planet.Core.wFe_ppt, wScore_ppt=Planet.Core.wS_ppt, etaSilFixed_Pas=Planet.Sil.etaRock_Pas, etaCoreFixed_Pas=[Planet.Core.etaFeSolid_Pas, Planet.Core.etaFeLiquid_Pas], + TviscTrans_K=Planet.Core.TviscTrans_K, + doConstantProps=Planet.Do.CONSTANT_INNER_DENSITY, constantProperties={'rho_kgm3': Planet.Core.rhoFe_kgm3, 'Cp_JkgK': np.nan, 'alpha_pK': np.nan, 'kTherm_WmK': Planet.Core.kTherm_WmK, + 'VP_kms': np.nan, 'VS_kms': np.nan, 'KS_GPa': np.nan, 'GS_GPa': Planet.Core.GSset_GPa, 'eta_Pas': Planet.Core.etaFeSolid_Pas, + 'sigma_Sm': Planet.Core.sigmaCore_Sm}) + log.profile(f'Inner EOS pre-generated for {len(innerPlanets)} inner planets.') + Params.PRELOAD_EOS_IN_PROGRESS = False + log.profile(f'Pre-generating EOS complete.') + return \ No newline at end of file diff --git a/PlanetProfile/Utilities/SummaryTables.py b/PlanetProfile/Utilities/SummaryTables.py index fa3992ee..0cb26b16 100644 --- a/PlanetProfile/Utilities/SummaryTables.py +++ b/PlanetProfile/Utilities/SummaryTables.py @@ -2,12 +2,119 @@ import logging from PlanetProfile.GetConfig import FigMisc, FigLbl from PlanetProfile.Utilities.Indexing import PhaseConv -from PlanetProfile.Utilities.defineStructs import Constants +from PlanetProfile.Utilities.defineStructs import Constants, FigureFilesSubstruct from PlanetProfile.MagneticInduction.Moments import Excitations - +from PlanetProfile.Utilities.ResultsStructs import ExplorationResultsStruct +from PlanetProfile.Utilities.ResultsIO import InductionCalced +import itertools +import os # Assign logger log = logging.getLogger('PlanetProfile') + +def GetExplorationComparisons(ExplorationList, Params): + """ Get comparisons between exploration results. To compare exploraitons, they must be for same body and same x and y variable/ranges. """ + ComparisonList = [] + FigureFilesList = [] + # Get all pairwise combinations of exploration results + combinations = itertools.combinations(range(0, len(ExplorationList)), 2) + # Get difference type + differenceType = FigMisc.EXPLOREOGRAM_COMPARISON_DIFFERENCE_TYPE + # For each comparison, get the comparison exploration results + for i, comparison in enumerate(combinations): + # Ensure that the two explorations are for the same body, x variable, x range, y variable, and y range, otherwise comparison doesn't make sense + FirstExplorationIndex = comparison[0] + SecondExplorationIndex = comparison[1] + FirstExploration = ExplorationList[FirstExplorationIndex] + SecondExploration = ExplorationList[SecondExplorationIndex] + sameBody = FirstExploration.bodyname == SecondExploration.bodyname + sameXVariable = FirstExploration.xName == SecondExploration.xName + sameXRange = np.all(FirstExploration.xData == SecondExploration.xData) + sameYVariable = FirstExploration.yName == SecondExploration.yName + sameYRange = np.all(FirstExploration.yData == SecondExploration.yData) + if not sameBody or not sameXVariable or not sameYVariable or not sameXRange or not sameYRange: + continue + else: + comparisonExploration = ExplorationResultsStruct() + comparisonExploration.bodyname = FirstExploration.bodyname + comparisonExploration.base.VALID = FirstExploration.base.VALID & SecondExploration.base.VALID + comparisonExploration.base.oceanComp = FirstExploration.base.oceanComp + comparisonExploration.xName = FirstExploration.xName + comparisonExploration.yName = FirstExploration.yName + comparisonExploration.nx = FirstExploration.nx + comparisonExploration.ny = FirstExploration.ny + comparisonExploration.xData = FirstExploration.xData + comparisonExploration.yData = FirstExploration.yData + comparisonExploration.base.wOcean_ppt = FirstExploration.base.wOcean_ppt + comparisonExploration.base.zb_approximate_km = FirstExploration.base.zb_approximate_km + comparisonExploration.base.Tb_K = FirstExploration.base.Tb_K + + # Get comparisons of z variables that can be plotted + comparisonExploration.base.CMR2mean = getDifference(FirstExploration.base.CMR2mean, SecondExploration.base.CMR2mean, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.D_km = getDifference(FirstExploration.base.D_km, SecondExploration.base.D_km, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.Dconv_m = getDifference(FirstExploration.base.Dconv_m, SecondExploration.base.Dconv_m, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.dzIceI_km = getDifference(FirstExploration.base.dzIceI_km, SecondExploration.base.dzIceI_km, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.dzClath_km = getDifference(FirstExploration.base.dzClath_km, SecondExploration.base.dzClath_km, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.dzIceIII_km = getDifference(FirstExploration.base.dzIceIII_km, SecondExploration.base.dzIceIII_km, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.dzIceIIIund_km = getDifference(FirstExploration.base.dzIceIIIund_km, SecondExploration.base.dzIceIIIund_km, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.dzIceV_km = getDifference(FirstExploration.base.dzIceV_km, SecondExploration.base.dzIceV_km, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.dzIceVund_km = getDifference(FirstExploration.base.dzIceVund_km, SecondExploration.base.dzIceVund_km, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.dzIceVI_km = getDifference(FirstExploration.base.dzIceVI_km, SecondExploration.base.dzIceVI_km, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.dzWetHPs_km = getDifference(FirstExploration.base.dzWetHPs_km, SecondExploration.base.dzWetHPs_km, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.eLid_km = getDifference(FirstExploration.base.eLid_km, SecondExploration.base.eLid_km, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.phiSeafloor_frac = getDifference(FirstExploration.base.phiSeafloor_frac, SecondExploration.base.phiSeafloor_frac, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.Rcore_km = getDifference(FirstExploration.base.Rcore_km, SecondExploration.base.Rcore_km, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.rhoSilMean_kgm3 = getDifference(FirstExploration.base.rhoSilMean_kgm3, SecondExploration.base.rhoSilMean_kgm3, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.rhoCoreMean_kgm3 = getDifference(FirstExploration.base.rhoCoreMean_kgm3, SecondExploration.base.rhoCoreMean_kgm3, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.sigmaMean_Sm = getDifference(FirstExploration.base.sigmaMean_Sm, SecondExploration.base.sigmaMean_Sm, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.silPhiCalc_frac = getDifference(FirstExploration.base.silPhiCalc_frac, SecondExploration.base.silPhiCalc_frac, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.zb_km = getDifference(FirstExploration.base.zb_km, SecondExploration.base.zb_km, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.zSeafloor_km = getDifference(FirstExploration.base.zSeafloor_km, SecondExploration.base.zSeafloor_km, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.qSurf_Wm2 = getDifference(FirstExploration.base.qSurf_Wm2, SecondExploration.base.qSurf_Wm2, comparisonExploration.base.VALID, differenceType) + + # Get tidal Love number comparisons + comparisonExploration.base.kLoveAmp = getDifference(FirstExploration.base.kLoveAmp, SecondExploration.base.kLoveAmp, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.hLoveAmp = getDifference(FirstExploration.base.hLoveAmp, SecondExploration.base.hLoveAmp, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.lLoveAmp = getDifference(FirstExploration.base.lLoveAmp, SecondExploration.base.lLoveAmp, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.deltaLoveAmp = getDifference(FirstExploration.base.deltaLoveAmp, SecondExploration.base.deltaLoveAmp, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.kLovePhase = getDifference(FirstExploration.base.kLovePhase, SecondExploration.base.kLovePhase, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.hLovePhase = getDifference(FirstExploration.base.hLovePhase, SecondExploration.base.hLovePhase, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.lLovePhase = getDifference(FirstExploration.base.lLovePhase, SecondExploration.base.lLovePhase, comparisonExploration.base.VALID, differenceType) + comparisonExploration.base.deltaLovePhase = getDifference(FirstExploration.base.deltaLovePhase, SecondExploration.base.deltaLovePhase, comparisonExploration.base.VALID, differenceType) + # Get magnetic induction comparions + if InductionCalced([FirstExploration, SecondExploration]) and np.all(FirstExploration.induction.calcedExc == SecondExploration.induction.calcedExc): + comparisonExploration.induction.Texc_hr = FirstExploration.induction.Texc_hr + comparisonExploration.induction.freq_Hz = FirstExploration.induction.freq_Hz + comparisonExploration.induction.calcedExc = FirstExploration.induction.calcedExc + comparisonExploration.induction.nPeaks = FirstExploration.induction.nPeaks + + comparisonExploration.induction.Amp = getDifference(FirstExploration.induction.Amp, SecondExploration.induction.Amp, comparisonExploration.base.VALID, differenceType) + comparisonExploration.induction.phase = getDifference(FirstExploration.induction.phase, SecondExploration.induction.phase, comparisonExploration.base.VALID, differenceType) + comparisonExploration.induction.Bix_nT = getDifference(FirstExploration.induction.Bix_nT, SecondExploration.induction.Bix_nT, comparisonExploration.base.VALID, differenceType) + comparisonExploration.induction.Biy_nT = getDifference(FirstExploration.induction.Biy_nT, SecondExploration.induction.Biy_nT, comparisonExploration.base.VALID, differenceType) + comparisonExploration.induction.Biz_nT = getDifference(FirstExploration.induction.Biz_nT, SecondExploration.induction.Biz_nT, comparisonExploration.base.VALID, differenceType) + comparisonExploration.induction.Bi1x_nT = getDifference(FirstExploration.induction.Bi1x_nT, SecondExploration.induction.Bi1x_nT, comparisonExploration.base.VALID, differenceType) + comparisonExploration.induction.Bi1y_nT = getDifference(FirstExploration.induction.Bi1y_nT, SecondExploration.induction.Bi1y_nT, comparisonExploration.base.VALID, differenceType) + comparisonExploration.induction.Bi1z_nT = getDifference(FirstExploration.induction.Bi1z_nT, SecondExploration.induction.Bi1z_nT, comparisonExploration.base.VALID, differenceType) + + comparisonExploration.induction.rBi1x_nT = getDifference(FirstExploration.induction.rBi1x_nT, SecondExploration.induction.rBi1x_nT, comparisonExploration.base.VALID, differenceType) + comparisonExploration.induction.rBi1y_nT = getDifference(FirstExploration.induction.rBi1y_nT, SecondExploration.induction.rBi1y_nT, comparisonExploration.base.VALID, differenceType) + comparisonExploration.induction.rBi1z_nT = getDifference(FirstExploration.induction.rBi1z_nT, SecondExploration.induction.rBi1z_nT, comparisonExploration.base.VALID, differenceType) + comparisonExploration.induction.iBi1x_nT = getDifference(FirstExploration.induction.iBi1x_nT, SecondExploration.induction.iBi1x_nT, comparisonExploration.base.VALID, differenceType) + comparisonExploration.induction.iBi1y_nT = getDifference(FirstExploration.induction.iBi1y_nT, SecondExploration.induction.iBi1y_nT, comparisonExploration.base.VALID, differenceType) + comparisonExploration.induction.iBi1z_nT = getDifference(FirstExploration.induction.iBi1z_nT, SecondExploration.induction.iBi1z_nT, comparisonExploration.base.VALID, differenceType) + comparisonExploration.induction.Bi1Tot_nT = getDifference(FirstExploration.induction.Bi1Tot_nT, SecondExploration.induction.Bi1Tot_nT, comparisonExploration.base.VALID, differenceType) + comparisonExploration.induction.rBi1Tot_nT = getDifference(FirstExploration.induction.rBi1Tot_nT, SecondExploration.induction.rBi1Tot_nT, comparisonExploration.base.VALID, differenceType) + comparisonExploration.induction.iBi1Tot_nT = getDifference(FirstExploration.induction.iBi1Tot_nT, SecondExploration.induction.iBi1Tot_nT, comparisonExploration.base.VALID, differenceType) + # Append ComparisonExploration to ComparisonList + ComparisonList.append(comparisonExploration) + # Create FigureFiles + comparePath = os.path.join(comparisonExploration.bodyname, 'figures') + compareBase = f'{comparisonExploration.bodyname}ComparisonxRange{Params.Explore.xRange[0]}_{Params.Explore.xRange[1]}yRange{Params.Explore.yRange[0]}_{Params.Explore.yRange[1]}' + FigureFiles = FigureFilesSubstruct(comparePath, compareBase, FigMisc.xtn, exploreAppend=Params.Explore.zName) + FigureFilesList.append(FigureFiles) + return ComparisonList, Params,FigureFilesList + def GetLayerMeans(PlanetList, Params): """ For calculating layer means we didn't need at any other point in our analysis, but that we might want to consider in comparing/analyzing profiles. @@ -1101,3 +1208,58 @@ def PrintTrajecTableLatex(FitOutputs, Params): """) return + + +def PercentDifference(x, y, validDataMask): + """ Calculate the percent difference between 2d or 3d arrays for exploration results""" + # Create output arrays with same shape as input, filled with NaN + percentDifference = np.full_like(x, np.nan, dtype=float) + # Calculate percent difference only for valid data points + if np.any(validDataMask): + if x.ndim == 2: + x_valid = x[validDataMask] + y_valid = y[validDataMask] + + diff = x_valid - y_valid + avg = (x_valid + y_valid) / 2 + percentDifference[validDataMask] = np.where(diff == 0, 0, abs(100 * diff / avg)) + else: + # Expand validDataMask to match first dimension of x + expandedValidDataMask = np.expand_dims(validDataMask, axis=0) + expandedValidDataMask = np.repeat(expandedValidDataMask, x.shape[0], axis=0) + x_valid = x[expandedValidDataMask] + y_valid = y[expandedValidDataMask] + diff = x_valid - y_valid + avg = (x_valid + y_valid) / 2 + percentDifference[expandedValidDataMask] = np.where(diff == 0, 0, abs(100 * diff / avg)) + return percentDifference + +def absoluteDifference(x, y, validDataMask): + """ Calculate the absolute difference between 2d or 3d arrays for exploration results""" + # Create output arrays with same shape as input, filled with NaN + absoluteDifference = np.full_like(x, np.nan, dtype=float) + # Calculate absolute difference only for valid data points + if np.any(validDataMask): + if x.ndim == 2: + x_valid = x[validDataMask] + y_valid = y[validDataMask] + + diff = x_valid - y_valid + absoluteDifference[validDataMask] = np.where(diff == 0, 0, abs(diff)) + else: + # Expand validDataMask to match first dimension of x + expandedValidDataMask = np.expand_dims(validDataMask, axis=0) + expandedValidDataMask = np.repeat(expandedValidDataMask, x.shape[0], axis=0) + x_valid = x[expandedValidDataMask] + y_valid = y[expandedValidDataMask] + diff = x_valid - y_valid + absoluteDifference[expandedValidDataMask] = np.where(diff == 0, 0, abs(diff)) + return absoluteDifference + +def getDifference(x, y, validDataMask, differenceType): + if differenceType == 'percent': + return PercentDifference(x, y, validDataMask) + elif differenceType == 'absolute': + return absoluteDifference(x, y, validDataMask) + else: + raise ValueError(f"Invalid difference type: {differenceType}") \ No newline at end of file diff --git a/PlanetProfile/Utilities/defineStructs.py b/PlanetProfile/Utilities/defineStructs.py index 71605a6f..2b230692 100644 --- a/PlanetProfile/Utilities/defineStructs.py +++ b/PlanetProfile/Utilities/defineStructs.py @@ -28,6 +28,7 @@ # Assign logger log = logging.getLogger('PlanetProfile') +timeLog = logging.getLogger('PlanetProfile.Timing') # Component lists zComps = ['Amp', 'Bx', 'By', 'Bz', 'Bcomps'] @@ -121,8 +122,11 @@ def __init__(self): self.NONHYDROSTATIC = False # Whether to use different lower bound for C/MR^2 matching commensurate with nonhydrostaticity resulting in an artificially high MoI value self.SKIP_POROUS_PHASE = False # Whether to assume pores are only filled with liquid, and skip phase calculations there. self.CONSTANT_GRAVITY = False # Whether to force gravity to be constant throughout each material layer, instead of recalculating self-consistently with each progressive layer. + self.CONSTANTPROPSEOS = False # Whether to use constant properties for EOS. Used for non-self-consistent modeling. self.OCEAN_PHASE_HIRES = False # Whether to use a high-resolution grid for phase equilibrium lookup table in ocean EOS. Currently only implemented for MgSO4. WARNING: Uses a lot of memory, potentially 20+ GB. self.USE_WOCEAN_PPT = True # Whether to use wOcean_ppt to match with ocean composition (in case of CustomSolution, we can set this to false if we do not want to specify w_ppt) + self.NON_SELF_CONSISTENT = False # Whether to use non-self-consistent modeling (using mean values for layer properties instead of detailed EOS calculations) + self.STILL_CALCULATE_BROKEN_PROPERTIES = False # Progromatically set flag for still calculating properties even if the model is invalid - namely, is set to True only when ALLOW_BROKEN_MODELS is True and the reason it is invalid is mismatch mass or CMR2 """ Layer step settings """ @@ -134,6 +138,7 @@ def __init__(self): self.nHydroMax = None # Derived working length of hydrosphere layers, gets truncated after layer calcs self.nOceanMax = None # Derived working length of ocean layers, also truncated after layer calcs self.nHydro = None # Derived final number of steps in hydrosphere + self.nOcean = None # Derived final number of steps in ocean self.nTotal = None # Total number of layers in profile self.nIbottom = None # Derived number of clathrate + ice I layers self.nIIIbottom = 0 # Derived number of clathrate + ice I + ice III layers @@ -148,29 +153,38 @@ def __init__(self): self.nPsHP = 150 # Number of interpolation steps to use for getting HP ice EOS (pressures) self.nTsHP = 100 # Number of interpolation steps to use for getting HP ice EOS (temperatures) self.nPoros = 10 # Number of steps in porosity to use in geometric series between phiMin and phiMax for porous rock when no core is present - self.iCond = [] # Logical array to select indices corresponding to surface ice I - self.iConv = [] # As above, for convecting ice I (and lower TBL) - self.iCondIII = [] # As above, for conducting ice III - self.iConvIII = [] # As above, for convecting ice III - self.iCondV = [] # As above, for conducting ice V - self.iConvV = [] # As above, for convecting ice V + self.iCond = [] # Logical array to select indices corresponding to surface conducting ice + self.iConv = [] # Logical array to select indices corresponding to surface convecting ice +""" Reaction structure """ +class ReactionSubstruct: + def __init__(self): + self.reaction = None + self.disequilibriumConcentrations = {} + self.useReferenceSpecies = False + self.useH2ORatio = False + self.referenceSpecies = None + self.mixingRatioToH2O = {} + self.relativeRatioToReferenceSpecies = {} + """For explorations""" + self.speciesRatioToChange = None + self.speciesToChangeMixingRatio = np.nan """ Hydrosphere assumptions """ class OceanSubstruct: def __init__(self): + self.Reaction = ReactionSubstruct() # Reaction object for calculating affinities related to reactions self.comp = None # Type of dominant dissolved salt in ocean. Options: 'Seawater', 'MgSO4', 'PureH2O', 'NH3', 'NaCl', 'none' self.wOcean_ppt = None # (Absolute) salinity: Mass concentration of above composition in parts per thousand (ppt) self.pH = None # pH of ocean (Only customizable for CustomSolution - pH for other compositions are overridden # automatically [See Constants] #HAVE TO DO - self.reaction = None # Reaction to consider in ocean - self.reactionDisequilibriumConcentrations = None # Concentration of reaction species at disequilibrium self.ClathDissoc = None # Subclass containing functions/options for evaluating clathrate dissociation conditions self.sigmaMean_Sm = np.nan # Mean conductivity across all ocean layers (linear average, ignoring spherical geometry effects) self.sigmaTop_Sm = np.nan # Conductivity of shallowest ocean layer self.deltaP = None # Increment of pressure between each layer in lower hydrosphere/ocean (sets profile resolution) self.deltaT = None # Step size in K for temperature values used in generating ocean EOS functions. If set, overrides calculations that otherwise use the specified precision in Tb_K to determine this. + self.propsStepReductionFactor = 1 # Optional factor to reduce resolution (increase deltaP and deltaT) specifically for EOS properties calculations. For high-resolution modeling, deltaP and deltaT are set low to get high-resolution phase grid, but this is not as necessary for the properties which will be interpolated, so can decrease the resoltuion to improve runtime and decrease memory usage. Default is 1, meaning no reduction. self.sigmaFixed_Sm = None # Optional setting to force ocean conductivity to be a certain uniform value. self.smoothingPolyOrder = 2 # Polynomial order to use for smoothing of melting-curve-following HP ice adiabats self.smoothingWindowOverride = 7 # Number of points to use for smoothing window when Do.FIXED_HPSMOOTH_WINDOW is True. Must be odd. @@ -178,6 +192,8 @@ def __init__(self): self.Vtot_m3 = None # Total volume of all ocean layers self.rhoMean_kgm3 = None # Mean density for ocean layers self.Tmean_K = None # Mean temperature of ocean layers based on total thermal energy + self.oceanConstantProperties = None # Constant properties for ocean layers if specified with Planet + self.constantProperties = {phase: np.nan for phase in ['Ih', 'II', 'III', 'V', 'VI', 'Clath']} # Constant properties for conducting ice layers if specified with Planet self.rhoCondMean_kgm3 = {phase: np.nan for phase in ['Ih', 'II', 'III', 'V', 'VI', 'Clath']} # Mean density for conducting ice layers self.rhoConvMean_kgm3 = {phase: np.nan for phase in ['Ih', 'II', 'III', 'V', 'VI', 'Clath']} # Mean density for convecting ice layers self.sigmaCondMean_Sm = {phase: np.nan for phase in ['Ih', 'II', 'III', 'V', 'VI', 'Clath']} # Mean conductivity for conducting ice layers @@ -186,6 +202,7 @@ def __init__(self): self.sigmaConvMean_Sm = {phase: np.nan for phase in ['Ih', 'II', 'III', 'V', 'VI', 'Clath']} # Mean conductivity for convecting ice layers self.GScondMean_GPa = {phase: np.nan for phase in ['Ih', 'II', 'III', 'V', 'VI', 'Clath']} # Mean shear modulus for conducting ice layers self.GSconvMean_GPa = {phase: np.nan for phase in ['Ih', 'II', 'III', 'V', 'VI', 'Clath']} # Mean shear modulus for convecting ice layers + self.Eact_kJmol = {phase: np.nan for phase in ['Ih', 'II', 'III', 'V', 'VI', 'Clath', 'MixedClathrateIh']} # Activation energy for diffusion of ice phases Ih-VI in kJ/mol (start at index 1) - Overrides Constants.Eact_kJmol if specified self.rhoMeanIIwet_kgm3 = np.nan # Mean density for in-ocean ice II layers self.rhoMeanIIIwet_kgm3 = np.nan # Mean density for in-ocean ice III layers self.rhoMeanVwet_kgm3 = np.nan # Mean density for in-ocean ice V layers @@ -199,15 +216,18 @@ def __init__(self): self.GSmeanVwet_GPa = np.nan # Mean shear modulus for in-ocean ice V layers self.GSmeanVI_GPa = np.nan # Mean shear modulus for in-ocean ice VI layers self.TfreezeOffset_K = 0.01 # Offset from the freezing temperature to avoid overshooting in HP ices - self.koThermI_WmK = 2.21 # Thermal conductivity of ice I at melting temp. Default is from Eq. 6.4 of Melinder (2007), ISBN: 978-91-7178-707-1 + # self.koThermI_WmK = 2.21 # Thermal conductivity of ice I at melting temp. Default is from Eq. 6.4 of Melinder (2007), ISBN: 978-91-7178-707-1 + self.kThermWater_WmK = None # Thermal conductivity for water layers - Overrides kThermWater_WmK in Constants + self.kThermIce_WmK = {phase: None for phase in ['Ih', 'II', 'III', 'V', 'VI', 'Clath']} # Constant thermal conductivity for each ice layer in non-self-consistent models self.dkdTI_WmK2 = -0.012 # Temperature derivative of ice I relative to the melting temp. Default is from Melinder (2007). - self.sigmaIce_Sm = {'Ih':1e-8, 'II':1e-8, 'III':1e-8, 'V':1e-8, 'VI':1e-8, 'Clath':5e-5} # Assumed conductivity of solid ice phases (see Constants.sigmaClath_Sm below) + self.sigmaIce_Sm = {'Ih':1e-8, 'II':1e-8, 'III':1e-8, 'V':1e-8, 'VI':1e-8, 'Clath':5e-5, 'MixedClathrateIh': 5e-5} # Assumed conductivity of solid ice phases (see Constants.sigmaClath_Sm below) self.THydroMax_K = 320 # Assumed maximum ocean temperature for generating ocean EOS functions. For large bodies like Ganymede, Callisto, and Titan, larger values are required. self.PHydroMax_MPa = 200 # Guessed maximum pressure of the hydrosphere in MPa. Must be greater than the actual pressure, but ideally not by much. Sets initial length of hydrosphere arrays, which get truncated after layer calculations are finished. self.MgSO4elecType = 'Vance2018' # Type of electrical conductivity model to use for MgSO4. Options: 'Vance2018', 'Pan2020' self.MgSO4scalingType = 'Vance2018' # Type of scaling to apply to Larionov and Kryukov model. Options: 'Vance2018', 'LK1984' self.MgSO4rhoType = 'Millero' # Type of water density model to use in Larionov and Kryukov model. Options: 'Millero', 'SeaFreeze' - self.phaseType = 'lookup' # Type of phase calculation to use for MgSO4 and pure water. Currently, "lookup" runs a fast lookup table like the Perplex EOS functions, and anything else forces a (slow) individual calc for each P, T point. + self.phaseType = 'lookup' # Type of phase calculation to use for MgSO4 and pure water. Currently, "lookup" runs a fast lookup table like the Perplex EOS functions. 'Lookup' dynamically generates phase grid based on input P, T steps by user (has been made very quick, so selected by default now). + #'calc' dynamically calculates phase at each P,T step. Most accurate but very slow. #'preload' uses a precomputed phase grid (only applicable for MgSO4) - most inaccurate phase grid. self.QfromMantle_W = None # Heat flow from mantle into hydrosphere (calculated from ice thermal profile and applied to mantle) self.EOS = None # Equation of state data to use for ocean layers self.meltEOS = None # EOS just for finding ice I/liquid transition pressure Pb @@ -232,12 +252,16 @@ def __init__(self): self.Jvisc = 1 # Derived ocean quantities self.Bulk_pHs = None # pH of each liquid layer for bulk ocean + self.pHSeafloor = None # pH at the seafloor + self.pHTop = None # pH at the top of the ocean self.aqueousSpecies = None # All species considered in each liquid ocean layer (i.e. the species considered in self.aqueousSpeciesAmount_mol = None # Species amount at each liquid ocean layer (nested 2D array of dimensions # np.size(aqueousSpecies) x len(total layers that are liquid)) - self.affinity_kJ = None # Affinity of Planet.Ocean.reaction (if specified) across ocean depth - self.affinityMean_kJ = None # Mean affinity of Planet.Ocean.reaction (if specified) across ocean depth - self.affinitySeafloor_kJ = None # Affinity of Planet.Ocean.reaction (if specified) at seafloor + self.affinity_kJ = None # Affinity of Planet.Ocean.Reaction.reaction (if specified) across ocean depth + self.affinityMean_kJ = None # Mean affinity of Planet.Ocean.Reaction.reaction (if specified) across ocean depth + self.affinitySeafloor_kJ = None # Affinity of Planet.Ocean.Reaction.reaction (if specified) at seafloor + self.mixingRatioToH2O = None # Mixing ratio of H2 to CO2 in the ocean + self.speciesOfRatio = None # Species of the ratio in the ocean @@ -255,6 +279,8 @@ def __init__(self): self.HtidalMax_Wm3 = 1e-7 # Maximum average tidal heating to stop MoI search self.deltaHtidal_logUnits = 1/3 # Step size by which to increment Htidal_Wm3 for finding MoI match with no core. self.kTherm_WmK = None # Constant thermal conductivity to set for a specific body (overrides Constants.kThermSil_WmK) + self.etaRock_Pas = None # Assumed viscosities of rock - Overrides Constants.etaRock_Pas if specified + self.TviscTrans_K = None # Transition temperatures for rock to go from one viscosity value to another - Overrides Constants.TviscRock_K if specified self.kThermCMB_WmK = None # Constant thermal conductivity to use for determining core-mantle boundary thermal boundary layer thickness when convection is happening """ Porosity parameters """ self.phiRockMax_frac = None # Porosity (void fraction) of the rocks in vacuum. This is the expected value for core-less bodies, and porosity is modeled for a range around here to find a matching MoI. For bodies with a core, this is a fixed value for rock porosity at P=0. @@ -297,16 +323,29 @@ def __init__(self): self.mantleEOSName = None # Same as above but containing keywords like clathrates in filenames self.mantleEOSDry = None # Name of mantle EOS to use assuming non-hydrated silicates self.EOS = None # Interpolator functions for evaluating Perple_X EOS model + """ Constant properties when using CONSTANT_INNER_DENSITY = True """ self.rhoSilWithCore_kgm3 = 3300 # Assumed density of rocks when a core is present in kg/m^3 + self.GSset_GPa = None # Assumed shear modulus in GPa for the silicate layers (overrides Constants.GS_GPa[Constants.phaseSil]) + self.VPset_kms = None # Assumed bulk modulus in km/s for the silicate layers (overrides Constants.VP_kms[Constants.phaseSil]) + self.VSset_kms = None # Assumed shear modulus in km/s for the silicate layers (overrides Constants.VS_kms[Constants.phaseSil]) + self.KSset_GPa = None # Assumed bulk modulus in GPa for the silicate layers (overrides Constants.KS_GPa[Constants.phaseSil]) + self.GSset_GPa = None # Assumed shear modulus in GPa for the silicate layers (overrides Constants.GS_GPa[Constants.phaseSil]) + self.sigmaSet_Sm = None # Assumed conductivity in S/m for the silicate layers (overrides Constants.sigma_Sm[Constants.phaseSil]) + + # Derived quantities self.Rmean_m = None # Mantle radius for mean compatible moment of inertia (MoI) self.Rrange_m = None # Mantle radius range for compatible MoI self.Rtrade_m = None # Array of mantle radii for compatible MoIs self.rhoMean_kgm3 = None # Mean mantle density determined from MoI calculations - self.GSmean_GPa = None # Mean shear modulus in silicate layers + self.GSmean_GPa = np.nan # Mean shear modulus in silicate layers + self.KSmean_GPa = np.nan # Mean bulk modulus in silicate layers + self.VSmean_kms = np.nan # Mean shear modulus in silicate layers + self.VPmean_kms = np.nan # Mean bulk modulus in silicate layers self.HtidalMean_Wm3 = None # Mean tidal heating in silicate layers self.sigmaMean_Sm = np.nan # Mean conductivity across all silicate layers, ignoring spherical effects self.rhoTrade_kgm3 = None # Array of mantle densities for compatible MoIs for core vs. mantle tradeoff plot + self.rhoNoCore_kgm3 = np.nan # Derived density of silicate layers when no core is present self.mFluids = None # WIP for tracking loss of fluids along the geotherm -- needs a better name. @@ -322,6 +361,15 @@ def __init__(self): self.coreEOS = 'Fe-S_3D_EOS.mat' # Default core EOS to use self.EOS = None # Interpolator functions for evaluating Perple_X EOS model self.kTherm_WmK = None # Constant thermal conductivity to set for a specific body (overrides Constants.kThermFe_WmK) + self.etaFeSolid_Pas = None # Assumed viscosity of solid iron in Pa*s - Overrides Constants.etaFeSolid_Pas if specified + self.etaFeLiquid_Pas = None # Assumed viscosity of liquid iron in Pa*s - Overrides Constants.etaFeLiquid_Pas if specified + self.TviscTrans_K = None # Transition temperatures for iron to go from one viscosity value to another - Overrides Constants.TviscFe_K if specified + """ Constant properties when using CONSTANT_INNER_DENSITY = True """ + self.GSset_GPa = None # Assumed shear modulus in GPa for the silicate layers (overrides Constants.GS_GPa[Constants.phaseSil]) + self.VPset_kms = None # Assumed bulk modulus in km/s for the silicate layers (overrides Constants.VP_kms[Constants.phaseSil]) + self.VSset_kms = None # Assumed shear modulus in km/s for the silicate layers (overrides Constants.VS_kms[Constants.phaseSil]) + self.KSset_GPa = None # Assumed bulk modulus in GPa for the silicate layers (overrides Constants.KS_GPa[Constants.phaseSil]) + self.sigmaSet_Sm = None # Assumed conductivity in S/m for the silicate layers (overrides Constants.sigma_Sm[Constants.phaseSil]) # Derived quantities self.rhoMean_kgm3 = None # Core bulk density calculated from final MoI match using EOS properties self.rhoMeanFe_kgm3 = np.nan # Pure iron layer bulk density calculated from final MoI match using EOS properties @@ -444,6 +492,7 @@ def __init__(self): self.qLin = None # Same as above but for q self.BinmLin_nT = None # Linear form of Binm_nT, with shape (nExc, (nPrmMax+pMax+1)**2 - 1), such that BinmLin[i, j] = Binm[i, int(m[j]<0), n[j], m[j]] self.Bi1xyz_nT = {'x': None, 'y': None, 'z': None} # Induced dipole surface strength in IAU components + self.Bi1Tot_nT = None # Induced dipole surface strength in total field # Fourier spectrum calculations self.FT_LOADED = False # Whether Fourier spectrum data has been loaded and calculated self.Be1xyzFT_nT = {'x': None, 'y': None, 'z': None} # Complex dipole vector components of excitation spectrum @@ -496,11 +545,19 @@ def __init__(self): self.BurgerFirstParameter = 0 # First parameter for Burgers layers self.BurgerSecondParameter = 0 # Second parameter for Burgers layers - # Calculated love numbers - 2d array of shape len(harmonic_degrees)xlen(time_log_kyrs) [see configPPgravity] + # Calculated complex love numbers - 2d array of shape len(harmonic_degrees)xlen(time_log_kyrs) [see configPPgravity] self.h = np.nan # h love number self.l = np.nan # l love number self.k = np.nan # k love number self.delta = np.nan # delta relationship between love numbers (1+k-h) + self.hAmp = np.nan # Amplitude of h love number + self.lAmp = np.nan # Amplitude of l love number + self.kAmp = np.nan # Amplitude of k love number + self.deltaAmp = np.nan # Amplitude of delta relationship between love numbers (1+k-h) + self.hPhase = np.nan # Phase of h love number + self.lPhase = np.nan # Phase of l love number + self.kPhase = np.nan # Phase of k love number + self.deltaPhase = np.nan # Phase of delta relationship between love numbers (1+k-h) """ Main body profile info--settings and variables """ @@ -525,7 +582,6 @@ def __init__(self, name): self.Reduced = ReducedPlanetStruct() self.Magnetic = MagneticSubstruct() self.Gravity = GravitySubstruct() - self.Model = ModelSubstruct() self.fname = None # Relative path used for .py file import self.saveLabel = None # Label for savefile @@ -542,8 +598,8 @@ def __init__(self, name): self.PfreezeRes_MPa = 0.05 # Step size in pressure for GetPfreeze to use in searching for phase transition # Settings for GetTfreeze start, stop, and step size. Used when ice shell thickness is input. self.TfreezeLower_K = 240 # Lower boundary for GetTFreeze to search for ice Ih phase transition - self.TfreezeUpper_K = 270 # Upper boundary for GetTFreeze to search for ice Ih phase transition - self.TfreezeRes_K = 0.01 # Step size in temperature for GetTfreeze to use in searching for phase transition + self.TfreezeUpper_K = 280 # Upper boundary for GetTFreeze to search for ice Ih phase transition + self.TfreezeRes_K = 0.05 # Step size in temperature for GetTfreeze to use in searching for phase transition """ Derived quantities (assigned during PlanetProfile runs) """ # Layer arrays @@ -589,6 +645,9 @@ def __init__(self, name): self.etaConv_Pas = None # Viscosity of ice I at Tconv_K self.etaConvIII_Pas = None # Same as above but for ice III underplate layers. self.etaConvV_Pas = None # Same as above but for ice V underplate layers. + self.etaMelt_Pas = None # Viscosity of ice I at Tb_K + self.etaMeltIII_Pas = None # Same as above but for ice III underplate layers. + self.etaMeltV_Pas = None # Same as above but for ice V underplate layers. self.eLid_m = None # Thickness of conducting stagnant lid layer in m. self.eLidIII_m = None # Same as above but for ice III underplate layers. self.eLidV_m = None # Same as above but for ice V underplate layers. @@ -650,6 +709,10 @@ def __init__(self, name): # Info for diagnosing out-of-bounds models self.invalidReason = None + + # Info for timing profiles + self.profileStartTime = None # Start time of profile + self.index = None # Index of profile - used for printing number of profiles complete """ Reduced planet struct """ class ReducedPlanetStruct: @@ -663,13 +726,13 @@ def __init__(self): self.eta_Pas = None # The reduced viscosity to use self.phaseConv = None # Truth array of convecting layers self.changeIndices = None # Indices of layer changes (either phase or convecting changes) - - + """ Params substructs """ # Construct filenames for data, saving/reloading class DataFilesSubstruct: def __init__(self, datPath, saveBase, comp, inductBase=None, exploreAppend=None, - inductAppend=None, EXPLORE=False): + inductAppend=None, monteCarloAppend=None, EXPLORE=False): + self.saveBase = saveBase if inductBase is None: inductBase = saveBase if exploreAppend is None: @@ -680,6 +743,10 @@ def __init__(self, datPath, saveBase, comp, inductBase=None, exploreAppend=None, self.inductAppend = '' else: self.inductAppend = inductAppend + if monteCarloAppend is None: + self.monteCarloAppend = '' + else: + self.monteCarloAppend = monteCarloAppend self.path = datPath self.inductPath = os.path.join(self.path, 'inductionData') @@ -687,6 +754,8 @@ def __init__(self, datPath, saveBase, comp, inductBase=None, exploreAppend=None, self.fNameSeis = os.path.join(self.seisPath, saveBase) self.gravityPath = os.path.join(self.path, 'gravityData') self.fNameGravity = os.path.join(self.gravityPath, saveBase) + self.montecarloPath = os.path.join(self.path, 'montecarloData') + self.fNameMonteCarlo = os.path.join(self.montecarloPath, saveBase) if not self.path == '': if not os.path.isdir(self.path): os.makedirs(self.path) @@ -698,6 +767,8 @@ def __init__(self, datPath, saveBase, comp, inductBase=None, exploreAppend=None, os.makedirs(self.fNameSeis) if not os.path.isdir(self.gravityPath): os.makedirs(self.gravityPath) + if not os.path.isdir(self.montecarloPath): + os.makedirs(self.montecarloPath) self.fName = os.path.join(self.path, saveBase) self.saveFile = self.fName + '.txt' @@ -709,14 +780,16 @@ def __init__(self, datPath, saveBase, comp, inductBase=None, exploreAppend=None, self.minEOSyanFile = os.path.join(self.fNameSeis, 'yannos.dat') self.AxiSEMfile = self.fNameSeis + '_AxiSEM.bm' self.fNameExplore = self.fName + f'_{self.exploreAppend}ExploreOgram' - self.exploreOgramFile = f'{self.fNameExplore}.mat' + self.exploreOgramFile = f'{self.fNameExplore}.pkl' + self.exploreOgramMatFile = f'{self.fNameExplore}.mat' self.invertOgramFile = f'{self.fNameExplore}Inversion.mat' self.fNameInduct = os.path.join(self.inductPath, saveBase) self.inductLayersFile = self.fNameInduct + '_inductLayers.txt' self.inducedMomentsFile = self.fNameInduct + '_inducedMoments.mat' self.fNameInductOgramBase = os.path.join(self.inductPath, inductBase) self.fNameInductOgram = os.path.join(self.inductPath, inductBase + self.inductAppend) - self.inductOgramFile = self.fNameInductOgram + f'_inductOgram.mat' + self.inductOgramFile = self.fNameInductOgram + f'_inductOgram.pkl' + self.inductOgramMatFile = self.fNameInductOgram + f'_inductOgram.mat' self.inductOgramSigmaFile = self.fNameInductOgram + '_sigma_inductOgram.mat' self.gravityParametersFile = self.fNameGravity + '_gravityParameters.txt' self.xRangeData = os.path.join(self.path, 'xRangeData.mat') @@ -725,12 +798,19 @@ def __init__(self, datPath, saveBase, comp, inductBase=None, exploreAppend=None, self.FTdata = os.path.join(self.inductPath, 'Bi1xyzFTdata.mat') self.asymFile = self.fNameInduct + '_asymDevs.mat' self.Btrajec = os.path.join(self.inductPath, f'{inductBase}{self.inductAppend}.mat') + self.montecarloFile = self.fNameMonteCarlo + '_montecarlo.pkl' + self.montecarloMatFile = self.fNameMonteCarlo + '_montecarlo.mat' # Construct filenames for figures etc. class FigureFilesSubstruct: - def __init__(self, figPath, figBase, xtn, comp=None, exploreBase=None, inductBase=None, - exploreAppend=None, inductAppend=None, flybys=None): + def __init__(self, figPath, figBase, xtn, comp=None, exploreBase=None, inductBase=None,monteCarloBase=None, + exploreAppend=None, inductAppend=None, monteCarloAppend=None,flybys=None, inputBaseOverride=None): + if inputBaseOverride is not None: + figBase = inputBaseOverride + exploreBase = inputBaseOverride + inductBase = inputBaseOverride + monteCarloBase = inputBaseOverride if inductBase is None: self.inductBase = figBase else: @@ -739,6 +819,11 @@ def __init__(self, figPath, figBase, xtn, comp=None, exploreBase=None, inductBas self.exploreBase = figBase else: self.exploreBase = exploreBase + self.inversionBase = figBase + if monteCarloBase is None: + self.monteCarloBase = figBase + else: + self.monteCarloBase = monteCarloBase if comp is None: self.comp = '' else: @@ -751,6 +836,10 @@ def __init__(self, figPath, figBase, xtn, comp=None, exploreBase=None, inductBas self.inductAppend = '' else: self.inductAppend = inductAppend + if monteCarloAppend is None: + self.monteCarloAppend = '' + else: + self.monteCarloAppend = monteCarloAppend if flybys is None: self.flybys = {'none': {'NA': ''}} else: @@ -758,21 +847,27 @@ def __init__(self, figPath, figBase, xtn, comp=None, exploreBase=None, inductBas self.xtn = xtn self.path = figPath self.inductPath = os.path.join(self.path, 'induction') + self.montecarloPath = os.path.join(self.path, 'montecarlo') if not self.path == '' and not os.path.isdir(self.path): os.makedirs(self.path) if not self.path == '' and not os.path.isdir(self.inductPath): os.makedirs(self.inductPath) + if not self.path == '' and not os.path.isdir(self.montecarloPath): + os.makedirs(self.montecarloPath) self.fName = os.path.join(self.path, figBase) self.fNameInduct = os.path.join(self.inductPath, self.inductBase + self.comp + self.inductAppend) self.fNameExplore = os.path.join(self.path, self.exploreBase) + self.fNameInversion = os.path.join(self.path, self.inversionBase) + self.fNameMonteCarlo = os.path.join(self.montecarloPath, self.monteCarloBase) self.fNameFlybys = os.path.join(self.inductPath, self.inductBase, os.path.dirname(figPath)) - + # Figure filename strings vpore = 'Porosity' vporeDbl = 'Porosity2axes' vperm = 'Permeability' vseis = 'Seismic' vhydro = 'Hydrosphere' + vhydroThermo = 'HydrosphereThermodynamics' vgrav = 'Gravity' vmant = 'MantleDens' vcore = 'CoreMantTrade' @@ -783,6 +878,7 @@ def __init__(self, figPath, figBase, xtn, comp=None, exploreBase=None, inductBas hydroSpecies = 'OceanSpecies' vwedg = 'Wedge' vphase = 'HydroPhase' + vmeltingCurves = 'MeltingCurves' induct = 'InductOgram' sigma = 'InductOgramSigma' Bdip = 'Bdip' @@ -800,6 +896,8 @@ def __init__(self, figPath, figBase, xtn, comp=None, exploreBase=None, inductBas AlfvenWing = 'AlfvenWings' asym = 'asymDevs' apsidal = 'apsidalPrec' + inversion = 'Inversion' + # Construct Figure Filenames self.vwedg = self.fName + vwedg + self.xtn self.vpore = self.fName + vpore + self.xtn @@ -807,10 +905,12 @@ def __init__(self, figPath, figBase, xtn, comp=None, exploreBase=None, inductBas self.vperm = self.fName + vperm + self.xtn self.vseis = self.fName + vseis + self.xtn self.vhydro = self.fName + vhydro + self.xtn + self.vhydroThermo = self.fName + vhydroThermo + self.xtn self.vgrav = self.fName + vgrav + self.xtn self.vmant = self.fName + vmant + self.xtn self.vcore = self.fName + vcore + self.xtn self.vphase = self.fName + vphase + self.xtn + self.vmeltingCurves = self.fName + vmeltingCurves + self.xtn self.vvisc = self.fName + vvisc + self.xtn self.vpvtHydro = self.fName + vpvtHydro + self.xtn self.isoThermvpvtHydro = self.fName + isoThermvpvtHydro + self.xtn @@ -818,13 +918,23 @@ def __init__(self, figPath, figBase, xtn, comp=None, exploreBase=None, inductBas self.vpvtPerpleX = self.fName + vpvtPerpleX + self.xtn self.asym = self.fName + asym self.apsidal = self.fName + apsidal + self.xtn + self.inversion = self.fName + inversion + self.xtn if isinstance(self.exploreAppend, list): self.explore = [f'{self.fNameExplore}_{eApp}{self.xtn}' for eApp in self.exploreAppend] self.exploreZbD = [f'{self.fNameExplore}_{eApp}_ZbD{self.xtn}' for eApp in self.exploreAppend] + self.exploreZbY = [f'{self.fNameExplore}_{eApp}_ZbY{self.xtn}' for eApp in self.exploreAppend] + exploreMultiSubplotExtension = '_'.join(eApp for eApp in self.exploreAppend) + if len(exploreMultiSubplotExtension) > 80: + log.warning(f"Explore multi-subplot filename is too long, truncating extension to 100 characters") + exploreMultiSubplotExtension = exploreMultiSubplotExtension[:80] + self.exploreMultiSubplot = f'{self.fNameExplore}_MultiSubplot_' + exploreMultiSubplotExtension + self.xtn else: self.explore = f'{self.fNameExplore}_{self.exploreAppend}{self.xtn}' self.exploreZbD = f'{self.fNameExplore}_ZbD{self.xtn}' + self.exploreZbY = f'{self.fNameExplore}_ZbY{self.xtn}' + self.exploreMultiSubplot = f'{self.fNameExplore}_MultiSubplot{self.xtn}' self.exploreDsigma = f'{self.fNameExplore}_Dsigma{self.xtn}' + self.exploreDY = f'{self.fNameExplore}_DY{self.xtn}' self.exploreLoveComparison = f'{self.fNameExplore}_LoveNumberComparison{self.xtn}' self.phaseSpace = f'{self.fNameInduct}_{induct}_phaseSpace{self.xtn}' self.phaseSpaceCombo = f'{os.path.join(self.inductPath, self.inductBase)}Compare_{induct}_phaseSpace{self.xtn}' @@ -833,6 +943,7 @@ def __init__(self, figPath, figBase, xtn, comp=None, exploreBase=None, inductBas self.sigma = {zType: f'{self.fNameInduct}_{sigma}_{zType}{self.xtn}' for zType in zComps} self.sigmaOnly = {zType: f'{self.fNameInduct}_{sigma}Only_{zType}{self.xtn}' for zType in zComps} self.Bdip = {axComp: f'{self.fNameInduct}_{Bdip}{axComp}{self.xtn}' for axComp in xyzComps + ['all']} + self.BdipExplore = {axComp: f'{self.fNameExplore}_{Bdip}{axComp}{self.xtn}' for axComp in xyzComps + ['all']} self.MagFT = f'{self.fNameInduct}_{MagFT}{self.xtn}' self.MagFTexc = f'{self.fNameInduct}_{MagFTexc}{self.xtn}' self.MagSurf = {vComp: f'{self.fNameInduct}_{MagSurf}B{vComp}' for vComp in vecComps} @@ -847,6 +958,13 @@ def __init__(self, figPath, figBase, xtn, comp=None, exploreBase=None, inductBas for fbID, fbName in fbList.items()} for scName, fbList in self.flybys.items()} self.AlfvenWing = {scName: {fbID: f'{self.fNameFlybys}{AlfvenWing}{scName}{fbName}{self.xtn}' for fbID, fbName in fbList.items()} for scName, fbList in self.flybys.items()} + + # Monte Carlo figure filenames + self.montecarloDistributions = f'{self.fNameMonteCarlo}_distributions{self.xtn}' + self.montecarloCorrelations = f'{self.fNameMonteCarlo}_correlations{self.xtn}' + self.montecarloResults = f'{self.fNameMonteCarlo}_results{self.xtn}' + self.montecarloOceanComps = f'{self.fNameMonteCarlo}_oceanComps{self.xtn}' + self.montecarloTiming = f'{self.fNameMonteCarlo}_timing{self.xtn}' def comparisonFileGenerator(self, Planet1Title, Planet2Title, plot_type): """ Generate comparison file names between two planet name inputs with the given file extension. Used for generating comparison pdfs @@ -863,12 +981,18 @@ def __init__(self): self.Sig = None # General induction settings self.Induct = None # Induction calculation settings self.Explore = None # ExploreOgram calculation settings + self.Inversion: InversionParamsStruct | None = None # Inversion calculation settings self.MagSpectrum = None # Excitation spectrum settings self.Trajec = None # Trajectory analysis settings self.cLevels = None # Contour level specifications self.cFmt = None # Format of contour labels + self.OverrideFigureBase = None # Override the base figure name for the figure files self.compareDir = 'Comparison' self.INVERSION_IN_PROGRESS = False # Flag for running inversion studies + self.INDUCTOGRAM_IN_PROGRESS = False + self.MONTECARLO_IN_PROGRESS = False + self.EXPLOREOGRAM_IN_PROGRESS = False + self.PRELOAD_EOS_IN_PROGRESS = False # Flag for pre-loading EOSs for faster profile runs """ Inductogram settings """ @@ -1037,7 +1161,6 @@ def __init__(self): # Parallel computing self.parallel = False # Use Parallel computing for PyALMA calculations. #TODO: Need to implement way to do this if Parallel already being used in Exploreogram # Parsing parameters - self.rheology_structure = ['elastic', 'newton', 'newton', 'maxwell'] # Rheology structure model, where each model corresponds to a layer (core to surface) self.layer_radius = False # Manually define transition in layers (see PyAlma.init.infer_rheology_pp). Set to False since we define transitions by ReducedPlanetStruct self.layer_radius_index = False # If set to true, layer_radius values will be treated as index (see PyAlma.init.infer_rheology_pp). Set to False since we define transitions by ReducedPlanetStruct @@ -1085,86 +1208,88 @@ def __init__(self): 'Qrad_Wkg': 'inner', 'qSurf_Wm2': 'inner', 'oceanComp': 'hydro', - 'zb_approximate_km': 'hydro' + 'zb_approximate_km': 'hydro', + 'mixingRatioToH2O': 'hydro' } - + self.exploreLogScale = ['mixingRatioToH2O'] self.provideExploreRange = ['oceanComp'] # List of explore options where user must provide the array to explore over + self.contourName = None # Name of variable to use for contours (if None, uses z variable). Allows plotting contours of one variable while coloring by another. -""" ExploreOgram results struct """ -class ExplorationStruct: +""" Monte Carlo parameter options """ +class MonteCarloParamsStruct: + def __init__(self): + self.nRuns = 1000 # Number of Monte Carlo runs to perform + self.seed = None # Random seed for reproducibility + self.useParallel = True # Whether to use parallel processing + + # Parameters to search over for different model types + self.paramsToSearchSelfConsistent = [] # Parameters for self-consistent models + self.paramsToSearchNonSelfConsistent = [] # Parameters for non-self-consistent models + + # Parameter distributions and ranges + self.paramsDistributions = {} # Dictionary of parameter name: distribution type + self.paramsRanges = {} # Dictionary of parameter name: [min, max] range + + # Output settings + self.saveResults = True # Whether to save results to file + self.showPlots = True # Whether to display plots + self.plotDistributions = True # Whether to plot parameter distributions + self.plotResults = True # Whether to plot Monte Carlo results distributions + self.plotOceanComps = False # Whether to plot results by ocean composition + self.plotCorrelations = True # Whether to plot parameter correlations + self.plotScatter = False # Whether to plot scatter plots of parameter pairs + self.scatterParams = None # List of [x_param, y_param] pairs to scatter plot + self.excSelectionScatter = None # Dict of which magnetic excitations to include in scatter plots + +""" Inversion parameter options """ +class InversionParamsStruct: def __init__(self): - self.bodyname = None # Name of body modeled. - self.NO_H2O = False # Whether the exploreogram is for a waterless body. - self.CMR2str = None # LaTeX-formatted string describing input moment of inertia and valid model range. - self.Cmeasured = None # Input moment of inertia to match against for all models. - self.Cupper = None # Upper bound for "valid" moment of inertia matches for all models. - self.Clower = None # Lower bound for "valid" moment of inertia matches for all models. - self.x = None # 2D data of x axis variable for exploreogram plots - self.y = None # 2D data of y axis variable for exploreogram plots - self.z = None # 2D data to plot as z axis of exploreogram plots - self.xName = None # Name of variable along x axis. Options are listed in defaultConfig.py. - self.yName = None # Name of variable along y axis. Options are listed in defaultConfig.py. - self.zName = None # Name of z variable. Options are listed in defaultConfig.py. - self.xScale = 'linear' - self.yScale = 'linear' - self.wOcean_ppt = None # Values of salinity in g/kg set. - self.oceanComp = None # Ocean composition set. - self.R_m = None # Body radius in m set. - self.Tb_K = None # Values of Bulk.Tb_K set. - self.xFeS = None # Values of core FeS mole fraction set. - self.zb_approximate_km = None # Values of zb_approximate_km, if used. - self.rhoSilInput_kgm3 = None # Values of silicate density in kg/m^3 set. - self.silPhi_frac = None # Values of Sil.phiRockMax_frac set. - self.icePhi_frac = None # Values of surfIceEOS[phaseStr].phiMax_frac set. - self.silPclosure_MPa = None # Values of Sil.Pclosure_MPa set. - self.icePclosure_MPa = None # Values of surfIceEOS[phaseStr].Pclosure_MPa set. - self.ionosTop_km = None # Values set of ionosphere upper cutoff altitude in km. - self.sigmaIonos_Sm = None # Values set of outermost ionosphere Pedersen conductivity in S/m. - self.Htidal_Wm3 = None # Values of Sil.Htidal_Wm3 set. - self.Qrad_Wkg = None # Values of Sil.Qrad_Wkg set. - self.rhoOceanMean_kgm3 = None # Values of Ocean.rhoMean_kgm3 result (also equal to those set for all but phi inductOtype). - self.rhoSilMean_kgm3 = None # Values of Sil.rhoMean_kgm3 result (also equal to those set for all but phi inductOtype). - self.rhoCoreMean_kgm3 = None # Values of Core.rhoMean_kgm3 result (also equal to those set for all but phi inductOtype). - self.sigmaMean_Sm = None # Mean ocean conductivity result in S/m. - self.sigmaTop_Sm = None # Ocean top conductivity result in S/m. - self.Tmean_K = None # Ocean mean temperature result in K. - self.D_km = None # Ocean layer thickness result in km. - self.zb_km = None # Upper ice shell thickness result (including any ice Ih, clathrates, ice III underplate, and ice V underplate) in km. - self.zSeafloor_km = None # Depth to bottom of ocean result (sum of zb and D) in km. - self.dzIceI_km = None # Thickness of surface ice Ih layer result in km. - self.dzClath_km = None # Thickness of clathrate layer result in surface ice shell (may be at top, bottom, or all of ice shell) in km. - self.dzIceII_km = None # Thickness of undersea ice II layer result in km. - self.dzIceIII_km = None # Thickness of undersea ice III layer result in km. - self.dzIceIIIund_km = None # Thickness of underplate ice III layer result in km. - self.dzIceV_km = None # Thickness of undersea ice V layer result in km. - self.dzIceVund_km = None # Thickness of underplate ice V layer result in km. - self.dzIceVI_km = None # Thickness of undersea ice VI layer result in km. - self.h_love_number = None # h love number - self.l_love_number = None # l love number - self.k_love_number = None # k love number - self.affinitySeafloor_kJ = None # Available energy for chemical reaction at seafloor (see Planet.Ocean.reaction for more details) - self.affinityMean_kJ = None # Mean available energy for chemical reaction (see Planet.Ocean.reaction for more details) - self.delta_love_number_relation = None # delta relation of love number (1+k-h) - self.dzWetHPs_km = None # Total resultant thickness of all undersea high-pressure ices (II, III, V, and VI) in km. - self.eLid_km = None # Thickness of surface stagnant-lid conductive ice layer result (may include Ih or clathrates or both) in km. - self.Rcore_km = None # Core radius result in km. - self.Pseafloor_MPa = None # Pressure at the bottom of the liquid ocean layer in MPa. - self.silPhiCalc_frac = None # Best-match result value for P=0 rock porosity in volume fraction. - self.phiSeafloor_frac = None # Rock porosity at the seafloor result in volume fraction. - self.CMR2calc = None # Best-match result value for CMR2mean, i.e. CMR2 value closest to Cmeasured within the specified uncertainty. - self.VALID = None # Flags for whether each profile found a valid solution - self.invalidReason = None # Explanation for why any invalid solution failed - - # Additional attributes filled for InvertOgram-flavored struct + """ Set which observations to invert """ + self.invertXName = None + self.invertYName = None + self.INVERT_GRAVITY = True + self.INVERT_INDUCTION = True + self.INVERT_JOINT = True + """ Parameters for inversion calculations """ self.Amp = None # Amplitude of dipole response (modulus of complex dipole response). self.phase = None # (Positive) phase delay in degrees. - self.RMSe = None # Root-mean-square errors between MAG data and net field forward model - self.chiSquared = None # Chi-squared parameter, the squared residuals divided by the number of degrees of freedom - self.stdDev = None # Standard deviation of data from overall mean - self.Rsquared = None # R^2 goodness-of-fit parameter - + self.InductionResponseUncertainty_nT = None # Uncertainity in dipole response in nT + self.kLoveAmp = None # k2 Love number + self.kLoveAmpUncertainity = None # Uncertainity in k2 Love number + self.hLoveAmp = None # h2 Love number + self.hLoveAmpUncertainity = None # Uncertainity in h2 Love number + self.Bi1xyz_nT = None # Surface strength of dipole response in IAU components + self.Bi1Tot_nT = None # Surface strength of dipole response in total field + + self.xTrueFit = None + self.yTrueFit = None + self.spacecraftUncertainties = {'Clipper': {'InductionResponseUncertainty_nT': 1.5, 'kLoveAmpUncertainity': 0.036} + } + """ Grid of models within uncertainty """ + self.gridWithinAllUncertainty = None + self.gridWithinGravityUncertainty = None + self.gridWithinInductionUncertainty = None + self.gridWithinkLoveAmpUncertainty = None + self.gridWithinhLoveAmpUncertainty = None + self.gridWithinBi1Tot_nTUncertainty = None + + def assignRealPlanetModel(self, Planet, xBestFit=None, yBestFit=None): + self.Amp = Planet.Magnetic.Amp + self.phase = Planet.Magnetic.phase + self.kLoveAmp = Planet.Gravity.kAmp + self.hLoveAmp = Planet.Gravity.hAmp + self.Bi1xyz_nT = Planet.Magnetic.Bi1xyz_nT + self.Bi1Tot_nT = Planet.Magnetic.Bi1Tot_nT + self.xTrueFit = xBestFit + self.yTrueFit = yBestFit + def setSpaceCraft(self, spacecraft): + self.InductionResponseUncertainty_nT = self.spacecraftUncertainties[spacecraft]['InductionResponseUncertainty_nT'] + self.kLoveAmpUncertainity = self.spacecraftUncertainties[spacecraft]['kLoveAmpUncertainity'] + self.hLoveAmpUncertainity = self.spacecraftUncertainties[spacecraft]['hLoveAmpUncertainity'] + + """ Figure color options """ class ColorStruct: @@ -1396,6 +1521,7 @@ def __init__(self): self.TS_ticks = None # Text size in pt for tick marks on radius scale self.TS_desc = None # Text size in pt for model description and label self.TS_super = None # Text size in pt for overall ("suptitle") label with multiple wedges + self.TS_axis = None # Text size in pt for axis labels self.LS_markRadii = None # Linestyle for radii mark line when toggled on self.LW_markRadii = None # Linewidth for radii mark line when toggled on @@ -1472,6 +1598,14 @@ def __init__(self): self.tCA_RELATIVE = True # Whether to display trajectory x axes in time relative to closest approach or absolute times self.sciLimits = None # Powers of 10 to use as limits on axis labels, e.g. [-2, 4] means anything < 0.01 or >= 10000 will use scientific notation. + # Font size settings + self.TS_hydroLabels = None # Font size for hydrosphere phase labels in pt + self.hydroTitleSize = None # Font size for hydrosphere title in pt + self.speciesSize = None # Font size for species labels in hydrosphere species diagrams + self.cLabelSize = None # Font size in pt for contour labels + self.cLabelPad = None # Padding in pt to set beside contour labels + self.hydroPhaseSize = None # Font size of label for phase in phase diagram + # General plot labels and settings self.Dlabel = r'Ocean thickness $D$ ($\si{km}$)' self.zbLabel = r'Ice shell thickness $z_b$ ($\si{km}$)' @@ -1485,6 +1619,7 @@ def __init__(self): self.dzIceVlabel = r'Undersea ice V thickness $dz_\mathrm{V}$ ($\si{km}$)' self.dzIceVundlabel = r'Underplate ice V thickness $dz_\mathrm{V,und}$ ($\si{km}$)' self.dzIceVIlabel = r'Ice VI layer thickness $dz_\mathrm{VI}$ ($\si{km}$)' + self.dConvlabel = r'Ice Ih convectivelayer thickness $dz_\mathrm{conv}$ ($\si{m}$)' self.dzWetHPslabel = r'Undersea high-pressure ice thickness $dz_\mathrm{HP}$ ($\si{km}$)' self.eLidlabel = r'Conductive lid thickness $e_\mathrm{lid}$ ($\si{km}$)' self.TbLabel = r'Ice bottom temp $T_b$ ($\si{K}$)' @@ -1496,6 +1631,7 @@ def __init__(self): self.GSlabel = r'Shear modulus $G_S$ ($\si{GPa}$)' self.CMR2label = r'Calculated axial moment of inertia $C/MR^2$' self.affinitySeafloorLabel = r'Chemical reaction affinity at seafloor $A_\mathrm{sea}$ ($\si{kJ/mol}$)' + self.affinityTopLabel = r'Chemical reaction affinity at top of ocean $A_\mathrm{top}$ ($\si{kJ/mol}$)' self.affinityMeanLabel = r'Mean chemical reaction affinity $A_\mathrm{mean}$ ($\si{kJ/mol}$)' self.rLabel = r'Radius $r$ ($\si{km}$)' self.zLabel = r'Depth $z$ ($\si{km}$)' @@ -1513,6 +1649,8 @@ def __init__(self): self.gravCompareTitle = r'Gravity and pressure comparison' self.hydroTitle = r' hydrosphere properties' self.hydroCompareTitle = r'Hydrosphere property comparison' + self.hydroThermoTitle = r' hydrosphere thermodynamics' + self.hydroThermoCompareTitle = r'Hydrosphere thermodynamics comparison' self.poreTitle = r' porosity' self.poreCompareTitle = r'Porosity comparison' self.seisTitle = r' seismic properties' @@ -1525,7 +1663,8 @@ def __init__(self): self.PvTtitleSil = r' silicate interior properties with geotherm' self.PvTtitleCore = r' silicate and core interior properties with geotherm' self.hydroPhaseTitle = r' phase diagram' - self.hydroSpeciesTitle = r' ocean species' + self.meltingCurvesTitle = r' melting curves' + self.hydroSpeciesTitle = r' ocean precipitation, aqueous speciation, pH, and reaction affinity' # Wedge diagram labels self.wedgeTitle = 'interior structure' @@ -1548,7 +1687,9 @@ def __init__(self): self.BdipZoomLabel = {axComp: r'$B^i_' + axComp + r'$ inset' for axComp in ['x', 'y', 'z']} self.BdipReLabel = {axComp: r'$\mathrm{Re}\{B^i_' + axComp + r'\}$ ($\si{nT}$)' for axComp in ['x', 'y', 'z']} self.BdipImLabel = {axComp: r'$\mathrm{Im}\{B^i_' + axComp + r'\}$ ($\si{nT}$)' for axComp in ['x', 'y', 'z']} - + self.BdipReTotLabel = r'$\mathrm{Re}\{B^i_{tot}\}$ ($\si{nT}$)' + self.BdipImTotLabel = r'$\mathrm{Im}\{B^i_{tot}\}$ ($\si{nT}$)' + self.Bi1Tot_nTLabel = r'$B^i_{tot}$' # InductOgram labels and axis scales self.plotTitles = ['Amplitude $A$', '$B_x$ component', '$B_y$ component', '$B_z$ component'] self.fLabels = ['Amp', 'Bx', 'By', 'Bz'] @@ -1573,8 +1714,7 @@ def __init__(self): 'phi': 'log', 'oceanComp': 'linear' } - - # ExploreOgram labels + self.ionosTopLabel = r'Ionosphere maximum altitude ($\si{km}$)' self.silPclosureLabel = r'Silicate pore closure pressure ($\si{MPa}$)' self.icePclosureLabel = r'Ice pore closure pressure ($\si{MPa}$)' @@ -1669,6 +1809,7 @@ def __init__(self): self.FBlabel = None # Exploration parameter-dependent settings + self.subplotExplorationTitle = None self.explorationTitle = None self.exploreCompareTitle = None self.xLabelExplore = None @@ -1677,12 +1818,20 @@ def __init__(self): self.yScaleExplore = None self.cbarLabelExplore = None self.cfmt = None + self.titleAddendum = None self.xMultExplore = 1 self.yMultExplore = 1 self.zMultExplore = 1 - + + # Exploration user-override settings + self.overrideSubplotExplorationTitle = None # Overrides subplotExplorationTitle + self.overrideExplorationTitle = None # Overrides explorationTitle + self.xCustomAxis = None # Custom x axis for exploration plots + self.yCustomAxis = None # Custom y axis for exploration plots + # Unit-dependent labels set by SetUnits self.rhoUnits = None + self.distanceUnits = None self.sigUnits = None self.PunitsFull = None self.PunitsHydro = None @@ -1698,6 +1847,7 @@ def __init__(self): self.alphaUnits = None self.affinityUnits = None self.hydrosphereSpeciesUnits = None + self.hydrosphereSolidSpeciesVolUnits = None self.wMult = None self.xMult = None self.phiMult = None @@ -1752,48 +1902,123 @@ def __init__(self): 'icePhi_frac', 'Htidal_Wm3', 'Qrad_Wkg', - 'qSurf_Wm2' + 'qSurf_Wm2', + 'mixingRatioToH2O' ] self.fineContoursExplore = [ 'affinitySeafloor_kJ', 'affinityMean_kJ', - 'CMR2calc', + 'CMR2mean', 'phiSeafloor_frac', 'sigmaMean_Sm', 'silPhiCalc_frac', 'zb_km', - 'h_love_number', - 'l_love_number', - 'k_love_number', - 'delta_love_number_relation' + 'hLoveAmp', + 'lLoveAmp', + 'kLoveAmp', + 'deltaLoveAmp', + 'kLovePhase', + 'lLovePhase', + 'hLovePhase', + 'deltaLovePhase', + 'affinityTop_kJ', + 'pHTop' ] + # Contour format strings for exploreograms self.cfmtExplore = { + 'Rcore_km': '%.0f', 'affinitySeafloor_kJ': '%.0f', 'affinityMean_kJ': '%.0f', - 'CMR2calc': '%.3f', + 'CMR2mean': '%.3f', 'phiSeafloor_frac': '%.2f', 'sigmaMean_Sm': None, 'silPhiCalc_frac': '%.2f', 'zb_km': None, - 'h_love_number': '%.2f', - 'l_love_number': '%.2f', - 'k_love_number': '%.2f', - 'delta_love_number_relation': '%.2f' + 'hLoveAmp': '%.2f', + 'lLoveAmp': '%.3f', + 'kLoveAmp': '%.3f', + 'deltaLoveAmp': '%.2f', + 'kLovePhase': '%.2f', + 'lLovePhase': '%.2f', + 'hLovePhase': '%.2f', + 'deltaLovePhase': '%.2f', + 'pHSeafloor': '%.0f', + 'affinityTop_kJ': '%.0f', + 'pHTop': '%.0f', + 'InductionBi1Tot_nT': '%.0f', + 'InductionrBi1Tot_nT': '%.0f', + 'InductioniBi1Tot_nT': '%.0f', + 'InductionrBi1x_nT': '%.0f', + 'InductionrBi1y_nT': '%.0f', + 'InductionrBi1z_nT': '%.0f', + 'InductioniBi1x_nT': '%.0f', + 'InductioniBi1y_nT': '%.0f', + 'InductioniBi1z_nT': '%.0f', } self.cbarfmtExplore = { + 'Rcore_km': '%.0f', 'affinitySeafloor_kJ': '%.0f', + 'affinityTop_kJ': '%.0f', 'affinityMean_kJ': '%.0f', - 'CMR2calc': '%.4f', + 'CMR2mean': '%.4f', 'phiSeafloor_frac': '%.2f', 'sigmaMean_Sm': None, 'silPhiCalc_frac': '%.2f', 'zb_km': '%.1f', - 'h_love_number': '%.2f', - 'l_love_number': '%.2f', - 'k_love_number': '%.2f', - 'delta_love_number_relation': '%.2f' - + 'hLoveAmp': '%.3f', + 'lLoveAmp': '%.3f', + 'kLoveAmp': '%.3f', + 'deltaLoveAmp': '%.3f', + 'kLovePhase': '%.4f', + 'lLovePhase': '%.4f', + 'hLovePhase': '%.4f', + 'deltaLovePhase': '%.4f', + 'pHSeafloor': '%.0f', + 'pHTop': '%.0f', + 'InductionBi1Tot_nT': '%.0f', + 'InductionrBi1Tot_nT': '%.0f', + 'InductioniBi1Tot_nT': '%.0f', + 'InductionrBi1x_nT': '%.0f', + 'InductionrBi1y_nT': '%.0f', + 'InductionrBi1z_nT': '%.0f', + 'InductioniBi1x_nT': '%.0f', + 'InductioniBi1y_nT': '%.0f', + 'InductioniBi1z_nT': '%.0f', + } + self.cSpacingsExplore = { + 'pHSeafloor': 1, + 'pHTop': 1, + 'kLoveAmp': 0.036, + 'hLoveAmp': 0.2, + 'InductionrBi1Tot_nT': 3, + 'InductioniBi1Tot_nT': 3, + 'InductionrBi1x_nT': 3, + 'InductionrBi1y_nT': 3, + 'InductionrBi1z_nT': 3, + 'InductioniBi1x_nT': 3, + 'InductioniBi1y_nT': 3, + 'InductioniBi1z_nT': 3, } + self.cTicksSpacingsExplore = { + 'Rcore_km': 10, + 'affinitySeafloor_kJ': 40, + 'affinityTop_kJ': 40, + 'kLoveAmp': 0.036, + 'hLoveAmp': 0.2, + 'InductionrBi1Tot_nT': 3, + 'InductioniBi1Tot_nT': 3, + 'InductionrBi1x_nT': 3, + 'InductionrBi1y_nT': 3, + 'InductionrBi1z_nT': 3, + 'InductioniBi1x_nT': 3, + 'InductioniBi1y_nT': 3, + 'InductioniBi1z_nT': 10, + } + # Variables for which to pin colormap center to zero (useful for variables that can be positive/negative) + self.cMapZero = { + 'affinitySeafloor_kJ', + 'affinityTop_kJ' + } self.exploreDescrip = { 'xFeS': 'core FeS mixing ratio', 'rhoSilInput_kgm3': 'rock density', @@ -1809,6 +2034,7 @@ def __init__(self): 'dzIceV_km': 'undersea ice V thickness', 'dzIceVund_km': 'underplate ice V thickness', 'dzIceVI_km': 'ice VI layer thickness', + 'Dconv_m': 'convecting layer thickness', 'dzWetHPs_km': 'undersea high-pressure ice thickness', 'eLid_km': 'ice conductive lid thickness', 'Pseafloor_MPa': 'seafloor pressure', @@ -1827,21 +2053,48 @@ def __init__(self): 'icePhi_frac': 'ice maximum porosity', 'icePclosure_MPa': 'ice pore closure pressure', 'Htidal_Wm3': 'rock tidal heating', - 'h_love_number': 'h love number', - 'l_love_number': 'l love number', - 'k_love_number': 'k love number', - 'delta_love_number_relation': '1+k-h', + 'hLoveAmp': 'tidal Love number $h_2$ amplitude', + 'lLoveAmp': 'tidal Love number $l_2$ amplitude', + 'kLoveAmp': 'tidal Love number $k_2$ amplitude', + 'deltaLoveAmp': 'tidal Love number $\delta_2$ amplitude', + 'hLovePhase': 'tidal Love number $h_2$ phase', + 'lLovePhase': 'tidal Love number $l_2$ phase', + 'kLovePhase': 'tidal Love number $k_2$ phase', + 'deltaLovePhase': 'tidal Love number $\delta_2$ phase', 'Qrad_Wkg': 'rock radiogenic heating', 'qSurf_Wm2': 'surface heat flux', - 'CMR2calc': 'axial moment of inertia', + 'CMR2mean': 'axial moment of inertia', 'affinitySeafloor_kJ': 'seafloor affinity for chemical reaction', - 'affinityMean_kJ': 'average affinity for chemical reaction' + 'affinityTop_kJ': 'top of ocean affinity for chemical reaction', + 'affinityMean_kJ': 'average affinity for chemical reaction', + 'pHSeafloor': 'seafloor pH', + 'pHTop': 'top of ocean pH', + 'zb_approximate_km': 'approximate ice shell thickness', + 'oceanComp': 'ocean composition', + 'mixingRatioToH2O': 'mixing ratio in the ocean', + 'InductionAmp': 'induction amplitude', + 'InductionPhase': 'induction phase', + 'InductionrBi1Tot_nT': 'induction real total', + 'InductioniBi1Tot_nT': 'induction imaginary total', + 'InductionrBi1x_nT': 'induction real x-component', + 'InductionrBi1y_nT': 'induction real y-component', + 'InductionrBi1z_nT': 'induction real z-component', + 'InductioniBi1x_nT': 'induction imaginary x-component', + 'InductioniBi1y_nT': 'induction imaginary y-component', + 'InductioniBi1z_nT': 'induction imaginary z-component', + 'rhoCoreMean_kgm3': 'core density', } self.tCArelDescrip = { 's': r'($\si{s}$)', 'min': r'($\si{min}$)', 'h': r'($\si{h}$)' } + self.zNamePlotRealImag = { + 'InductionBi1Tot_nT': ('InductionrBi1Tot_nT', 'InductioniBi1Tot_nT'), + 'InductionBi1x_nT': ('InductionrBi1x_nT', 'InductioniBi1x_nT'), + 'InductionBi1y_nT': ('InductionrBi1y_nT', 'InductioniBi1y_nT'), + 'InductionBi1z_nT': ('InductionrBi1z_nT', 'InductioniBi1z_nT'), + } def SetUnits(self): @@ -1863,6 +2116,7 @@ def SetUnits(self): self.alphaUnits = r'K^{-1}' self.affinityUnits = r'kJ\,mol^{-1}' self.hydrosphereSpeciesUnits = r'mol\,kg^{-1}' + self.hydrosphereSolidSpeciesVolUnits = r'cm^3' else: self.rhoUnits = r'kg/m^3' self.sigUnits = r'S/m' @@ -1877,7 +2131,9 @@ def SetUnits(self): self.kThermUnits = r'W/m/K' self.alphaUnits = '1/K' self.affinityUnits = r'kJ/mol' - + self.hydrosphereSpeciesUnits = r'mol/kg' + self.hydrosphereSolidSpeciesVolUnits = r'cm^3' + self.distanceUnits = r'$\si{km}$' self.PunitsFull = 'MPa' self.PmultFull = 1 self.PunitsHydro = 'MPa' @@ -1966,12 +2222,14 @@ def SetUnits(self): self.rhoSilLabel = r'Rock density $\rho_\mathrm{rock}$ ($\si{' + self.rhoUnits + '}$)' self.rhoOceanMeanLabel = r'Ocean density $\overline{\rho}_\mathrm{ocean}$ ($\si{' + self.rhoUnits + '}$)' self.rhoSilMeanLabel = r'Rock density $\overline{\rho}_\mathrm{rock}$ ($\si{' + self.rhoUnits + '}$)' + self.rhoCoreMeanLabel = r'Core density $\overline{\rho}_\mathrm{core}$ ($\si{' + self.rhoUnits + '}$)' self.silPhiSeaLabel = r'Seafloor porosity $\phi_\mathrm{rock}$' + self.phiUnitsParen self.phiLabel = r'Porosity $\phi$' + self.phiUnitsParen - self.oceanCompLabel = r'Ocean composition' - self.zbApproximateLabel = r'Approximate ice shell thickness ($\si{km}$)' + self.zbApproximateLabel = r'Ice shell thickness ($\si{km}$)' + self.mixingRatioToH2OLabel = r'Mixing ratio in the ocean' self.rxnAffinityLabel = r'Affinity ($\si{' + self.affinityUnits + '}$)' self.allOceanSpeciesLabel = r'All species ($\si{' + self.hydrosphereSpeciesUnits + '}$)' + self.solidSpeciesLabel = r'Solid species ($\si{' + self.hydrosphereSolidSpeciesVolUnits + '}$)' self.aqueousSpeciesLabel = r'Aqueous species ($\si{' + self.hydrosphereSpeciesUnits + '}$)' self.vSoundLabel = r'Sound speeds $V_P$, $V_S$ ($\si{' + self.vSoundUnits + '}$)' self.vPoceanLabel = r'Ocean $V_P$ ($\si{' + self.vSoundUnits + '}$)' @@ -1982,10 +2240,14 @@ def SetUnits(self): self.KSoceanLabel = r'Ocean $K_S$ ($\si{GPa}$)' self.QseisLabel = f'Seismic quality factor ${self.QseisVar}$' self.xFeSLabel = r'Iron sulfide mixing ratio $x_{\ce{FeS}}$' + self.xUnitsParen - self.hLoveLabel = r'Tidal Love Number $h_2$' - self.lLoveLabel = r'Tidal Love Number $l_2$' - self.kLoveLabel = r'Tidal Love Number $k_2$' - self.deltaLoveLabel = r'$\Delta = 1 + k_2 - h_2$' + self.hLoveAmpLabel = r'Tidal Love $h_2$ Amplitude' + self.lLoveAmpLabel = r'Tidal Love $l_2$ Amplitude' + self.kLoveAmpLabel = r'Tidal Love $k_2$ Amplitude' + self.deltaLoveAmpLabel = r'$\delta_2$ ($= 1 + k_2 - h_2$) Amplitude' + self.hLovePhaseLabel = r'Tidal Love $h_2$ phase delay' + self.lLovePhaseLabel = r'Tidal Love $l_2$ phase delay' + self.kLovePhaseLabel = r'Tidal Love $k_2$ phase delay' + self.deltaLovePhaseLabel = r'$\delta_2$ ($= 1 + k_2 - h_2$) phase delay' self.qSurfLabel = r'Surface heat flux $q_\mathrm{surf}$ ($\si{' + self.fluxUnits + '}$)' self.silPhiInLabel = r'Rock maximum porosity search value $\phi_\mathrm{rock,max,in}$' + self.phiUnitsParen self.silPhiOutLabel = r'Rock maximum porosity match $\phi_\mathrm{rock,max}$' + self.phiUnitsParen @@ -2000,7 +2262,7 @@ def SetUnits(self): self.VPlabel = r'P-wave speed $V_P$ ($\si{' + self.vSoundUnits + '}$)' self.VSlabel = r'S-wave speed $V_S$ ($\si{' + self.vSoundUnits + '}$)' self.tPastJ2000 = 'Time after J2000 ($\si{' + self.tJ2000units + '}$)' - + self.xLabelsInduct = { 'sigma': self.sigLabel, 'Tb': self.wLabel, @@ -2031,6 +2293,7 @@ def SetUnits(self): 'dzIceV_km': self.dzIceVlabel, 'dzIceVund_km': self.dzIceVundlabel, 'dzIceVI_km': self.dzIceVIlabel, + 'Dconv_m': self.dConvlabel, 'dzWetHPs_km': self.dzWetHPslabel, 'eLid_km': self.eLidlabel, 'Pseafloor_MPa': self.PseafloorLabel, @@ -2041,6 +2304,7 @@ def SetUnits(self): 'sigmaMean_Sm': self.sigmaMeanLabel, 'rhoOceanMean_kgm3': self.rhoOceanMeanLabel, 'rhoSilMean_kgm3': self.rhoSilMeanLabel, + 'rhoCoreMean_kgm3': self.rhoCoreMeanLabel, 'silPhi_frac': self.silPhiInLabel, 'silPhiCalc_frac': self.silPhiOutLabel, 'phiSeafloor_frac': self.silPhiSeaLabel, @@ -2049,14 +2313,34 @@ def SetUnits(self): 'icePclosure_MPa': self.icePclosureLabel, 'Htidal_Wm3': self.HtidalLabel, 'Qrad_Wkg': self.QradLabel, - 'h_love_number': self.hLoveLabel, - 'l_love_number': self.lLoveLabel, - 'k_love_number': self.kLoveLabel, - 'delta_love_number_relation': self.deltaLoveLabel, + 'hLoveAmp': self.hLoveAmpLabel, + 'lLoveAmp': self.lLoveAmpLabel, + 'kLoveAmp': self.kLoveAmpLabel, + 'deltaLoveAmp': self.deltaLoveAmpLabel, + 'hLovePhase': self.hLovePhaseLabel, + 'lLovePhase': self.lLovePhaseLabel, + 'kLovePhase': self.kLovePhaseLabel, + 'deltaLovePhase': self.deltaLovePhaseLabel, 'qSurf_Wm2': self.qSurfLabel, - 'CMR2calc': self.CMR2label, + 'CMR2mean': self.CMR2label, 'affinitySeafloor_kJ': self.affinitySeafloorLabel, + 'affinityTop_kJ': self.affinityTopLabel, 'affinityMean_kJ': self.affinityMeanLabel, + 'pHSeafloor': self.pHLabel, + 'pHTop': self.pHLabel, + 'zb_approximate_km': self.zbApproximateLabel, + 'mixingRatioToH2O': self.mixingRatioToH2OLabel, + 'InductionAmp': self.plotTitles[0], + 'InductionPhase': self.phaseTitle, + 'InductionrBi1Tot_nT': self.BdipReTotLabel, + 'InductioniBi1Tot_nT': self.BdipImTotLabel, + 'InductionrBi1x_nT': self.BdipReLabel['x'], + 'InductionrBi1y_nT': self.BdipReLabel['y'], + 'InductionrBi1z_nT': self.BdipReLabel['z'], + 'InductioniBi1x_nT': self.BdipImLabel['x'], + 'InductioniBi1y_nT': self.BdipImLabel['y'], + 'InductioniBi1z_nT': self.BdipImLabel['z'], + 'oceanComp': self.oceanCompLabel, } self.axisMultsExplore = { 'xFeS': self.xMult, @@ -2065,6 +2349,7 @@ def SetUnits(self): 'icePhi_frac': self.phiMult, 'qSurf_Wm2': self.qMult } + self.axisCustomScalesExplore = {} # Custom x and y axes for exploreograms that should be toggled by user # Set sciLimits generally plt.rcParams['axes.formatter.limits'] = self.sciLimits @@ -2089,7 +2374,7 @@ def SetInduction(self, bodyname, IndParams, Texc_h): self.xScaleInduct = self.xScalesInduct[IndParams.inductOtype] self.yScaleInduct = self.yScalesInduct[IndParams.inductOtype] - def SetExploration(self, bodyname, xName, yName, zName, titleData=None): + def SetExploration(self, bodyname, xName, yName, zName, contourName = None, excName = None,titleData=None): # Set titles, labels, and axis settings pertaining to exploreogram plots self.xLabelExplore = '' if xName not in self.axisLabelsExplore.keys() else self.axisLabelsExplore[xName] self.xScaleExplore = 'log' if xName in self.axisLogScalesExplore else 'linear' @@ -2098,22 +2383,55 @@ def SetExploration(self, bodyname, xName, yName, zName, titleData=None): self.yScaleExplore = 'log' if yName in self.axisLogScalesExplore else 'linear' self.yMultExplore = 1 if yName not in self.axisMultsExplore.keys() else self.axisMultsExplore[yName] self.cbarLabelExplore = '' if zName not in self.axisLabelsExplore.keys() else self.axisLabelsExplore[zName] + self.cTicksSpacingExplore = None if zName not in self.cTicksSpacingsExplore.keys() else self.cTicksSpacingsExplore[zName] self.zMultExplore = 1 if zName not in self.axisMultsExplore.keys() else self.axisMultsExplore[zName] - self.cfmt = '%1.0f' if zName not in self.fineContoursExplore else self.cfmtExplore[zName] + if contourName is not None: + self.cfmt = '%1.0f' if contourName not in self.fineContoursExplore else self.cfmtExplore[contourName] + self.cSpacingExplore = None if contourName not in self.cSpacingsExplore else self.cSpacingsExplore[contourName] + else: + self.cfmt = '%1.0f' if zName not in self.fineContoursExplore else self.cfmtExplore[zName] + self.cSpacingExplore = None if zName not in self.cSpacingsExplore else self.cSpacingsExplore[zName] + if excName is not None: + excLabel = excName + self.cbarLabelExplore = f'{self.cbarLabelExplore}$_{{{excLabel}}}$' self.cbarFmt = None if zName not in self.fineContoursExplore else self.cbarfmtExplore[zName] - - self.SetExploreTitle(bodyname, zName, titleData) + self.xCustomAxis = None if xName not in self.axisCustomScalesExplore.keys() else self.axisCustomScalesExplore[xName] + self.yCustomAxis = None if yName not in self.axisCustomScalesExplore.keys() else self.axisCustomScalesExplore[yName] + self.SetExploreTitle(bodyname, xName, yName, zName, titleData, excName) + self.SetExploreYTitle(bodyname, yName, titleData) self.explorationDsigmaTitle = f'\\textbf{{{bodyname} ocean $D/\\sigma$ vs.\\ {self.exploreDescrip[zName]}}}' - self.explorationLoveComparisonTitle = f'\\textbf{{{bodyname} $\Delta = 1 + k_2 - h_2$ vs.\\ Tidal Love Number $k_2$ vs.\\ {self.exploreDescrip[zName]}}}' + self.explorationLoveComparisonTitle = f'\\textbf{{{bodyname} $\delta_2$ vs.\\ $k_2$' self.exploreCompareTitle = self.explorationTitle - - def SetExploreTitle(self, bodyname, zName, titleData): - # Set title for exploreogram plots + def SetInversion(self, bodyname, xName, yName, titleData = None): + self.inversionTitle = f'\\textbf{{{bodyname} inversion results}}' + self.xLabelExplore = '' if xName not in self.axisLabelsExplore.keys() else self.axisLabelsExplore[xName] + self.xScaleExplore = 'log' if xName in self.axisLogScalesExplore else 'linear' + self.xMultExplore = 1 if xName not in self.axisMultsExplore.keys() else self.axisMultsExplore[xName] + self.yLabelExplore = '' if yName not in self.axisLabelsExplore.keys() else self.axisLabelsExplore[yName] + self.yScaleExplore = 'log' if yName in self.axisLogScalesExplore else 'linear' + self.yMultExplore = 1 if yName not in self.axisMultsExplore.keys() else self.axisMultsExplore[yName] + self.xCustomAxis = None if xName not in self.axisCustomScalesExplore.keys() else self.axisCustomScalesExplore[xName] + self.yCustomAxis = None if yName not in self.axisCustomScalesExplore.keys() else self.axisCustomScalesExplore[yName] + def SetExploreTitle(self, bodyname, xName, yName, zName, titleData, excName): if titleData is None: self.explorationTitle = f'\\textbf{{{bodyname} {self.exploreDescrip[zName]} exploration}}' + self.subplotExplorationTitle = f'\\textbf{{{bodyname} Properties across {self.exploreDescrip[xName]} and {self.exploreDescrip[yName]}}}' else: self.explorationTitle = f'\\textbf{{{bodyname} {self.exploreDescrip[zName]} exploration, {titleData}}}' - + self.subplotExplorationTitle = f'\\textbf{{{bodyname} Properties across {self.exploreDescrip[xName]} and {self.exploreDescrip[yName]}, {titleData}}}' + # Set title for exploreogram plots + if self.overrideSubplotExplorationTitle is not None: + self.subplotExplorationTitle = self.overrideSubplotExplorationTitle + if self.overrideExplorationTitle is not None: + self.explorationTitle = self.overrideExplorationTitle + if excName is not None: + self.explorationTitle += f', {excName}' + def SetExploreYTitle(self, bodyname, yName, titleData): + # Set y-axis title for exploreogram plots + if titleData is None: + self.explorationYTitle = f'\\textbf{{{bodyname} {self.exploreDescrip[yName]}}}' + else: + self.explorationYTtile = f'\\textbf{{{bodyname} {self.exploreDescrip[yName]}, {titleData}}}' def rStr(self, rinEval_Rp, bodyname): # Get r strings to add to titles and log messages for magnetic field surface plots @@ -2240,12 +2558,14 @@ def __init__(self): self.vperm = None self.vseis = None self.vhydro = None + self.vhydroThermo = None self.vgrav = None self.vmant = None self.vcore = None self.vpvt = None self.vwedg = None self.vphase = None + self.vmeltingCurves = None self.vhydroSpecies = None self.explore = None self.phaseSpaceSolo = None @@ -2266,6 +2586,7 @@ def __init__(self): self.AlfvenWing = None self.asym = None self.apsidal = None + self.imaginaryRealSoloCombo = None "Custom ocean solution input settings" class CustomSolutionParamsStruct: @@ -2287,36 +2608,6 @@ def setPaths(self, ROOT): self.rktPath = os.path.join(ROOT, 'Thermodynamics', 'Reaktoro') self.databasePath = os.path.join(self.rktPath, 'Databases') self.frezchemPath = os.path.join(self.databasePath, self.FREZCHEM_DATABASE) -class ModelSubstruct: - def __init__(self): - # Initialize ice layer parameter dictionaries - self.T_K = {} - self.P_MPa = {} - self.rho_kgm3 = {} - self.Cp_JkgK = {} - self.alpha_pK = {} - self.kTherm_WmK = {} - self.phi_frac = {} - self.sigma_Sm = {} - self.Htidal_Wm3 = {} - self.Tb_K = {} - self.Pb_MPa = {} - self.thickness_km = {} - - # Set default values for each ice phase - for phase in ['Ih', 'III', 'V']: - self.T_K[phase] = None - self.P_MPa[phase] = None - self.rho_kgm3[phase] = None - self.Cp_JkgK[phase] = None - self.alpha_pK[phase] = None - self.kTherm_WmK[phase] = None - self.phi_frac[phase] = None - self.sigma_Sm[phase] = None - self.Htidal_Wm3[phase] = None - self.Tb_K[phase] = None - self.Pb_MPa[phase] = None - self.thickness_km[phase] = None # For configuring longitudes from -180 to 180 or 0 to 360. def LonFormatter(longitude, EAST=True): @@ -2391,6 +2682,8 @@ def __init__(self): self.lowSigCutoff_Sm = None # Cutoff conductivity below which profiles will be excluded. Setting to None includes all profiles self.TminHydro = 200 # Minimum temperature to display on hydrosphere plots self.PHASE_LABELS = False # Whether to print phase labels on density plots + self.TS_hydroLabels = None # Font size for hydrosphere phase labels in pt + self.hydroTitleSize = None # Font size for hydrosphere title in pt # Wedge diagrams self.IONOSPHERE_IN_WEDGE = False # Whether to include specified ionosphere in wedge diagram @@ -2415,6 +2708,31 @@ def __init__(self): self.TmaxHydro_K = None # When set, maximum temperature to use for hydrosphere and phase diagram PT plots in K. Set to None to use max of geotherm. self.hydroPhaseSize = None # Font size of label for phase in phase diagram self.TS_hydroLabels = None # Font size for hydrosphere phase labels in pt + self.PLOT_DENSITY_VERSUS_DEPTH = False # Whether to plot density versus depth instead of pressure + self.propsToPlot = None # Properties to plot in PvT or IsoTherm plots. Options are - 'rho', 'Cp', 'alpha', 'VP', 'KS', 'sig', 'VS', 'GS' + + # Hydrosphere isobaric plots + self.TtoPlot_K = None # Temperatures to plot in isobaric configuration + + # Hydrosphere species diagrams + self.minAqueousThreshold = None # Minimum mol of species needed to be considered to plot on hydrosphere species diagram + self.minVolSolidThreshold_cm3 = None # Minimum volume of solid needed to be considered to plot on hydrosphere species diagram + self.excludeSpeciesFromHydrospherePlot = None # Species to exclude from the hydrosphere plots + self.aqueousSpeciesLabels = None # Aqueous species to include in the aqueous-specific hydrosphere plot + self.gasSpeciesLabels = None # Solid species to include in the aqueous-specific hydrosphere plot + self.speciesSize = None # Font size for species labels in hydrosphere species diagrams + + # Melting curve plots + self.SHOW_GEOTHERM = False # Whether to show geotherm curves on melting curve plots + self.MARK_MODEL_POINTS = True # Whether to mark the model melting points (Tb_K, Pb_MPa) on melting curves + self.MELTING_CURVE_RESOLUTION = 100 # Number of points to use for melting curve calculation + self.MELTING_CURVE_LINE_WIDTH = 2.0 # Line width for melting curves + self.MODEL_POINT_SIZE = 50 # Marker size for model melting points + self.nTmeltingCurve = None # Number of temperature points to use for melting curve calculation + self.nPmeltingCurve = None # Number of pressure points to use for melting curve calculation + self.LS_SOLID_MELTING_CURVES = True # Whether to use solid linestyle for melting curves, ignoring LS style of ocean composition already specified + self.TmaxMeltingCurve_K = None # When set, maximum temperature to use for melting curves in K. Set to None to use max of geotherm. + self.TminMeltingCurve_K = None # When set, minimum temperature to use for melting curves in K. Set to None to use min of geotherm. # Silicate/core PT diagrams self.nTgeo = None # Number of temperature points to evaluate/plot for PT property plots @@ -2492,6 +2810,9 @@ def __init__(self): self.MARK_BEXC_MAX = True # Whether to annotate excitation spectrum plots with label for highest peak self.peakLblSize = None # Font size in pt for highest-peak annotation self.Tmin_hr = None # Cutoff period to limit range of Fourier space plots + # Exploreogram settings + self.EXPLOREOGRAM_SMOOTHING = False # Whether to smooth the exploreogram plots by interpolating to a finer grid + self.EXPLOREOGRAM_SMOOTHING_FACTOR = 10 # Factor to use for smoothing the exploreogram plots by interpolating to a finer grid when EXPLOREOGRAM_SMOOTHING is True # Exploreogram D/sigma settings self.DRAW_COMPOSITION_LINE = False # Whether to draw a line for each composition in the exploreogram D/sigma plot self.SHOW_ICE_THICKNESS_DOTS = False # Whether to show ice thickness dots instead of colorbar in D/sigma plots @@ -2517,7 +2838,8 @@ def __init__(self): self.LOVE_COMP_LEGEND_FONT_SIZE = 10 # Font size for composition legend entries in Love plots self.LOVE_COMP_LEGEND_TITLE_SIZE = 12 # Font size for composition legend title in Love plots self.LOVE_MAX_LEGEND_ENTRIES = 10 # Maximum number of entries to show in ice thickness legend for Love plots - # Exploreogram ZbD (ice shell vs ocean thickness) settings + self.SHOW_CONVECTION_WITH_SHAPE = False # Whether to use different marker shapes for convection vs non-convection in Love comparison plots + # Exploreogram ZbD (ice shell vs ocean thickness) settings self.ZBD_DOT_EDGE_COLOR = 'black' # Edge color for scatter dots in ZbD plots self.ZBD_DOT_EDGE_WIDTH = 0.5 # Edge line width for scatter dots in ZbD plots self.ZBD_COMP_LINE_WIDTH = 2 # Line width for composition lines in ZbD plots @@ -2532,7 +2854,18 @@ def __init__(self): self.SHOW_ERROR_BARS = False # Whether to show error bars on plots self.ERROR_BAR_MAGNITUDE = 0.01 # Magnitude of error bars to show on plots - + # Explore-o-gram XY plot settings + self.XY_X_VARIABLE = 'zb_approximate_km' # X variable to use for XY exploreogram plots (default: ocean thickness) + + # Multi-subplot figure settings + self.MULTI_SUBPLOT_FIGURE_SIZE_SCALE = 1.0 # Scale factor for figure size in multi-subplot plots + + # Subplot label settings for multi-subplot figures + self.SUBPLOT_LABELS = True # Whether to add labels (a, b, c, etc.) to subplots + self.SUBPLOT_LABEL_X = 0.02 # X position of subplot labels in axes coordinates (0-1) + self.SUBPLOT_LABEL_Y = 0.98 # Y position of subplot labels in axes coordinates (0-1) + self.SUBPLOT_LABEL_FONTSIZE = 12 # Font size for subplot labels + # Legends self.REFS_IN_LEGEND = True # Hydrosphere plot: Whether to include reference profiles in legend self.legendFontSize = None # Font size to use in legends, set by rcParams. @@ -2548,6 +2881,9 @@ def __init__(self): self.COMP_ROW = True # Whether to force composition into a row instead of printing a separate summary table for each ocean comp self.BODY_NAME_ROW = True # Whether to print a row with body name in bold in summary table + # Custom solution settings + self.CustomSolutionSingleCmap = True # Whether to use a single colormap for all custom solution plots - where each custom solution is a different color based on the colormap. + # Colorbar settings self.cbarTitleSize = None # Font size specifier for colorbar titles self.cbarFmt = '%.1f' # Format string to use for colorbar units @@ -2861,6 +3197,40 @@ def __init__(self): loaded['CustomSolutionEOS'] = {} # Dict listing the loaded EOSs for CustomSolution. We separate these EOS to save them all to disk at end ranges = {} # Dict listing the P, T ranges of the loaded EOSs. +""" Global timing object""" +class TimingStruct: + def __init__(self): + pass + startTime = np.nan + functionTime = np.nan + previousTime = np.nan + currentTime = np.nan + startingTime = [] + + + def setStartingTime(self, startTime): + self.startTime = startTime + self.previousTime = startTime + def setFunctionTime(self, functionTime): + self.functionTime = functionTime + self.previousTime = functionTime + def setTime(self, time): + self.startingTime.append(time) + def logTime(self, message, time): + timeLog.timing(f"{message}: {time - self.startingTime[-1]:.4f} seconds") + self.startingTime.pop() + def printTimeDifference(self, message, time): + timeDiff = time - self.previousTime + self.previousTime = time + timeLog.timing(f"{message}: {timeDiff:.2f} seconds") + def printFunctionTimeDifference(self, message, currentTime): + self.currentTime = currentTime + timeDiff = currentTime - self.functionTime + timeLog.timing(f"{message}: {timeDiff:.2f} seconds") + def printTotalTimeDifference(self, message): + timeDiff = self.currentTime - self.startTime + timeLog.timing(f"{message}: {timeDiff:.2f} seconds") + """ Physical constants """ class ConstantsStruct: @@ -2900,11 +3270,19 @@ def __init__(self): 'Fe': np.nan, 'FeS': np.nan } + self.STP_kgm3 = {'Ih': 917, '0': 1000} # Density of ices at standard temperature and pressure (STP) - used for first order Tb and Pb calculation for input ice shell thickness self.m_gmol['PureH2O'] = self.m_gmol['H2O'] # Add alias for H2O so that we can use the Ocean.comp string for dict entry self.mClathGas_gmol = self.m_gmol['CH4'] + 5.75 * self.m_gmol['H2O'] # Molecular mass of clathrate unit cell self.clathGasFrac_ppt = 1e3 * self.m_gmol['CH4'] / self.mClathGas_gmol # Mass fraction of gases trapped in clathrates in ppt self.QScore = 1e4 # Fixed QS value to use for core layers if not set in PPBody.py file + self.alphaIce_pK = {'Ih': 1.6e-4, 'II': 1.6e-4, 'III': 1.6e-4, 'V': 1.6e-4, 'VI': 1.6e-4} # Thermal expansivity of ice phases Ih-VI in 1/K + self.alphaWater_pK = 2.1e-4 # Thermal expansivity of water in 1/K + self.CpWater_JkgK = 4.184e3 # Heat capacity of water in J/(kg K) + self.Cp_JkgK = {'Ih': 2.108e3, 'II': 2.108e3, 'III': 2.108e3, 'V': 2.108e3, 'VI': 2.108e3} # Heat capacity of ice phases Ih-VI in J/(kg K) + self.VPOcean_kms = 1.4 # Fixed bulk sound speed of ocean in km/s + self.VSOcean_kms = 0.0 # Fixed shear sound speed of ocean in km/s self.kThermWater_WmK = 0.55 # Fixed thermal conductivity of liquid water in W/(m K) + self.kThermIce_WmK = {'Ih': 2.1, 'II': 2.1, 'III': 2.1, 'V': 2.1, 'VI': 2.1} # Fixed thermal of ice phases Ih-VI in W/(m K) self.kThermSil_WmK = 4.0 # Fixed thermal conductivity of silicates in W/(m K) self.kThermFe_WmK = 33.3 # Fixed thermal conductivity of core material in W/(m K) self.phaseClath = 30 # Phase ID to use for (sI methane) clathrates. Must be larger than 6 to keep space for pure ice phases @@ -2917,7 +3295,11 @@ def __init__(self): self.sigmaCO2Clath_Sm = 6.5e-4 # Also from Stern et al. (2021), at 273 K and 25% gas-filled porosity self.EactCO2Clath_kJmol = 46.5 # Also from Stern et al. (2021) # Initialize activation energies and melting point viscosities, for use in convection calculations - self.Eact_kJmol, self.etaMelt_Pas, self.EYoung_GPa = (np.ones(self.phaseClath+7) * np.nan for _ in range(3)) + self.Eact_kJmol, self.etaMelt_Pas, self.EYoung_GPa, self.VP_GPa, self.VS_GPa, self.KS_GPa, self.GS_GPa = (np.ones(self.phaseFeSolid+7) * np.nan for _ in range(7)) + self.GS_GPa[1:7] = np.array([2, 2, 2, np.nan, 2, 2]) # Mean shear modulus of ice phases Ih-VI in GPa + self.VP_GPa[1:7] = np.array([1.4, 1.4, 1.4, np.nan, 1.4, 1.4]) # Mean bulk modulus of ice phases Ih-VI in GPa + self.GS_GPa[self.phaseSil] = 50 # Shear modulus of silicates in GPa + self.GS_GPa[self.phaseFe] = 50 # Shear modulus of iron core material in GPa self.Eact_kJmol[1:7] = np.array([59.4, 76.5, 127, np.nan, 136, 110]) # Activation energy for diffusion of ice phases Ih-VI in kJ/mol self.Eact_kJmol[self.phaseClath] = 90.0 # From Durham et al. (2003), at 50 and 100 MPa and 260-283 K: https://doi.org/10.1029/2002JB001872 self.Eact_kJmol[self.phaseClath+1:self.phaseClath+7] = (self.Eact_kJmol[self.phaseClath] + self.Eact_kJmol[1:7]) / 2 # Average of clathrate and ice activation energies @@ -2969,7 +3351,6 @@ def __init__(self): self.EOSPmax_MPa = 2000 # Maximum pressure to make EOS lookup table for self.EOSdeltaP_For_Extrapolation = 5 # Extrapolation pressure step - self.rhoIce_kg_m3_stp = {'Ih': 917} # Density of ice at standard temperature and pressure (STP) - used for first order Tb and Pb calculation for input ice shell thickness self.PhreeqcToSupcrtNames = { # Dictionary of species names that must be converted from Phreeqc to Supcrt for compatibility 'H2O': 'H2O(aq)', @@ -2982,7 +3363,7 @@ def __init__(self): 'Sulfates': ["Alunite", "Anglesite", "Anhydrite", "Barite", "Celestite"] # Solids with SO4 in formula } - self.seafreeze_ice_phases = {0: 'water', 1: 'Ih', 2: 'II', 3: 'III', 5: 'V', 6: 'VI'} + self.seafreeze_ice_phases = {0: 'water1', 1: 'Ih', 2: 'II', 3: 'III', 5: 'V', 6: 'VI'} def ParentName(bodyname): if bodyname in ['Io', 'Europa', 'Ganymede', 'Callisto']: @@ -3001,3 +3382,4 @@ def ParentName(bodyname): Constants = ConstantsStruct() EOSlist = EOSlistStruct() +Timing = TimingStruct() diff --git a/PlanetProfile/Utilities/reducedPlanetModel.py b/PlanetProfile/Utilities/reducedPlanetModel.py index 4cfd177d..d167e9b5 100644 --- a/PlanetProfile/Utilities/reducedPlanetModel.py +++ b/PlanetProfile/Utilities/reducedPlanetModel.py @@ -9,7 +9,7 @@ # Assign logger log = logging.getLogger('PlanetProfile') -def GetReducedPlanetProfile(Planet, Params): +def GetReducedPlanet(Planet, Params): """ Generate a reduced planet profile to be used in magnetic induction and/or gravity calculations #TODO Only implemented for reduced layer currently, and not used for magnetic induction calculations until it can be further stress tested""" if Params.REDUCE_ACCORDING_TO == 'MagneticInduction': diff --git a/PlanetProfile/__init__.py b/PlanetProfile/__init__.py index d315266f..e35c5864 100644 --- a/PlanetProfile/__init__.py +++ b/PlanetProfile/__init__.py @@ -42,7 +42,7 @@ def RemoveCarefully(file): _defaultConfigTrajec = os.path.join(_ROOT, 'TrajecAnalysis', 'defaultConfigTrajec.py') _defaultConfigCustomSolution = os.path.join(_ROOT, 'CustomSolution', 'defaultConfigCustomSolution.py') _defaultConfigGravity = os.path.join(_ROOT, 'Gravity', 'defaultConfigGravity.py') -_defaultConfigModel = os.path.join(_ROOT, 'Model', 'defaultConfigModel.py') +_defaultConfigInversion = os.path.join(_ROOT, 'Inversion', 'defaultConfigInversion.py') _Defaults = os.path.join(_ROOT, 'Default') _DefaultList = next(os.walk(_Defaults))[1] _Test = os.path.join(_ROOT, 'Test') @@ -52,18 +52,18 @@ def RemoveCarefully(file): _SPICE = os.path.join(_ROOT, 'SPICE') # Copy user config files to local dir if the user does not have them yet -_userConfig = 'configPP.py' -_userConfigPlots = 'configPPplots.py' -_userConfigInduct = 'configPPinduct.py' -_userConfigTrajec = 'configPPtrajec.py' -_userConfigCustomSolution = 'configPPcustomsolution.py' -_userConfigGravity = 'configPPgravity.py' -_userConfigModel = 'configPPmodel.py' -configTemplates = [_defaultConfig, _defaultConfigPlots, _defaultConfigInduct, _defaultConfigTrajec, _defaultConfigCustomSolution, _defaultConfigGravity, _defaultConfigModel] -configLocals = [_userConfig, _userConfigPlots, _userConfigInduct, _userConfigTrajec, _userConfigCustomSolution, _userConfigGravity, _userConfigModel] +_userConfig = os.path.join('UserConfigs', 'configPP.py') +_userConfigPlots = os.path.join('UserConfigs', 'configPPplots.py') +_userConfigInduct = os.path.join('UserConfigs', 'configPPinduct.py') +_userConfigTrajec = os.path.join('UserConfigs', 'configPPtrajec.py') +_userConfigCustomSolution = os.path.join('UserConfigs', 'configPPcustomsolution.py') +_userConfigGravity = os.path.join('UserConfigs', 'configPPgravity.py') +_userConfigInversion = os.path.join('UserConfigs', 'configPPinversion.py') +configTemplates = [_defaultConfig, _defaultConfigPlots, _defaultConfigInduct, _defaultConfigTrajec, _defaultConfigCustomSolution, _defaultConfigGravity, _defaultConfigInversion] +configLocals = [_userConfig, _userConfigPlots, _userConfigInduct, _userConfigTrajec, _userConfigCustomSolution, _userConfigGravity, _userConfigInversion] if any([not os.path.isfile(cfg) for cfg in configLocals]): - if input(f'configPP files not found in pwd: {os.getcwd()}. Copy from defaults to local dir? ' + - f'[y]/n ') in ['', 'y', 'Y', 'yes', 'Yes']: + if input(f'configPP files not found in pwd: {os.path.join(os.getcwd(), "UserConfigs")}. Copy from defaults to local dir? ' + + f'y/n ') in ['', 'y', 'Y', 'yes', 'Yes', '[y]']: for template, local in zip(configTemplates, configLocals): CopyOnlyIfNeeded(template, local) diff --git a/PlanetProfile/defaultConfig.py b/PlanetProfile/defaultConfig.py index 44950382..9cb4826e 100644 --- a/PlanetProfile/defaultConfig.py +++ b/PlanetProfile/defaultConfig.py @@ -5,7 +5,7 @@ import os from PlanetProfile.Utilities.defineStructs import ParamsStruct, ExploreParamsStruct, Constants -configVersion = 20 # Integer number for config file version. Increment when new settings are added to the default config file. +configVersion = 23 # Integer number for config file version. Increment when new settings are added to the default config file. def configAssign(): Params = ParamsStruct() @@ -13,8 +13,11 @@ def configAssign(): Params.VERBOSE = False # Provides extra runtime messages. Overrides QUIET below Params.QUIET = False # Hides all log messages except warnings and errors + Params.TIMING = False # Whether to print timing messages to analyze performance + Params.PRINT_COMPLETION = True # Print completion message Params.QUIET_MOONMAG = True # If True, sets MoonMag logging level to WARNING, otherwise uses the same as PlanetProfile. Params.QUIET_LBF = True # If True, sets lbftd and mlbspline logging levels to ERROR, otherwise uses the same as PlanetProfile. + Params.QUIET_ALMA = True # If True, sets pyAlma logging level Warning, otherwise uses the same as PlanetProfile. Params.printFmt = '[%(levelname)s] %(message)s' # Format for printing log messages # The below flags allow or prevents extrapolation of EOS functions beyond the definition grid. Params.EXTRAP_ICE = {'Ih':False, 'II':False, 'III':False, 'V':False, 'VI':False, 'Clath':False} @@ -25,7 +28,9 @@ def configAssign(): Params.lookupInterpMethod = 'nearest' # Interpolation method to use for EOS lookup tables. Options are 'nearest', 'linear', 'cubic'. Params.minPres_MPa = None # Only applies to ice EOS! Applies a lower bound to how small the pressure step can be in loading the EOS. Avoids major slowdowns when chaining models for small and large bodies. Params.minTres_K = None # Same as above. Set to None to allow default behavior for ice EOS resolution. - + Params.PRELOAD_EOS = True # Whether to preload EOS tables for all planets in the grid for large scale explorations. Improves runtime. + Params.SAVE_AS_MATLAB = True # Whether to also save results also in MATLAB format (.mat files) + Params.CALC_NEW = True # Recalculate profiles? If not, read data from disk and re-plot. Params.CALC_NEW_REF = True # Recalculate reference melting curve densities? Params.CALC_NEW_INDUCT = True # Recalculate magnetic induction responses? @@ -34,9 +39,9 @@ def configAssign(): Params.CALC_SEISMIC = True # Calculate sound speeds and elastic moduli? Params.CALC_CONDUCT = True # Calculate electrical conductivity? Params.CALC_VISCOSITY = True # Calculate viscosity for all layers as a post-processing step? + Params.CALC_OCEAN_PROPS = True # Calculate ocean properties? Params.CALC_ASYM = True # Calculate induction with asymmetric shape? Params.RUN_ALL_PROFILES = False # Whether to run all PPBody.py files for the named body and plot together - Params.SPEC_FILE = False # Whether we are running a specific file or files Params.COMPARE = False # Whether to plot each new run against other runs from the same body Params.DO_PARALLEL = True # Whether to use multiprocessing module for parallel computation where applicable Params.threadLimit = 1000 # Upper limit to number of processors/threads for parallel computation @@ -53,6 +58,8 @@ def configAssign(): Params.SKIP_PLOTS = False # Whether to skip creation of all plots Params.PLOT_GRAVITY = True # Whether to plot Gravity and Pressure Params.PLOT_HYDROSPHERE = True # Whether to plot Conductivity with Interior Properties (Hydrosphere) + Params.PLOT_HYDROSPHERE_THERMODYNAMICS = False # Whether to plot thermodynamic properties (T, P, rho, alpha, Cp) vs depth in hydrosphere + Params.PLOT_MELTING_CURVES = False # Whether to plot melting curves in P-T space for all ocean compositions modeled Params.PLOT_SPECIES_HYDROSPHERE = True # Whether to plot aqueous species concentration as a function of ocean depth Params.PLOT_REF = True # Whether to plot reference melting curve densities on hydrosphere plot Params.PLOT_SIGS = True # Whether to plot conductivities as a function of radius on hydrosphere plot if they have been calculated @@ -77,8 +84,8 @@ def configAssign(): # Reduced planet calculation settings Params.REDUCE_ACCORDING_TO = 'ReducedLayers' # Whether to reduce according to induction parameters (change in sigma) or gravity settings (not implemented currently) - Params.REDUCED_LAYERS_SIZE = {'0': 5, 'Ih': 5, 'II': 5, 'III': 5, 'V': 5,'VI': 5, 'Clath': 5, 'Sil': 5, - 'Fe': 5} # If using ReducedLayers method, then determine how many layers the reduced layer should have # If using ReducedLayers method, then determine how many layers the reduced layer should have + Params.REDUCED_LAYERS_SIZE = {'0': 50, 'Ih': 50, 'II': 50, 'III': 50, 'V': 50,'VI': 50, 'Clath': 50, 'Sil': 50, + 'Fe': 50} # If using ReducedLayers method, then determine how many layers the reduced layer should have # If using ReducedLayers method, then determine how many layers the reduced layer should have # Magnetic induction plot settings Params.DO_INDUCTOGRAM = False # Whether to evaluate and/or plot an inductogram for the body in question @@ -93,21 +100,27 @@ def configAssign(): Params.SKIP_INDUCTION = False # Whether to skip past induction calculations. Primarily intended to avoid duplicate calculations in exploreOgrams Params.SKIP_GRAVITY = False # Whether to skip past gravity calculations. Primarily intended to avoid duplicate calculations in exploreOgrams Params.PLOT_INDIVIDUAL_PLANET_PLOTS = False # Whether to plot individual Planet runs that are explore as part of explore-o-gram. By default, this is false since it saves time and disk space for large induction studies. For smaller induction studies where individual plots are still desired, this can be useful to set to True. + Params.PLOT_D_SIGMA = False # Whether to plot D vs sigma for the exploreogram + Params.PLOT_LOVE_COMPARISON = False # Whether to plot love number comparison for the exploreogram + Params.PLOT_ZB_D = False # Whether to plot Zb vs D for the exploreogram # Options for x/y variables: "xFeS", "rhoSilInput_kgm3", "oceanComp", "wOcean_ppt", "Tb_K", "ionosTop_km", "sigmaIonos_Sm", # "silPhi_frac", "silPclosure_MPa", "icePhi_frac", "icePclosure_MPa", "Htidal_Wm3", "Qrad_Wkg", "zb_approximate_km", "qSurf_Wm2" (Do.NO_H2O only) # For "oceanComp" option, must provide a .mat file titled xRangeData.mat or yRangeData.mat of a dictionary whose key 'Data' corresponds to a list of ocean comps to query over. Exploreparams.nx/ny should match lens of list. ExploreParams.xName = 'wOcean_ppt' # x variable over which to iterate for exploreograms. Options are as above. ExploreParams.yName = 'Tb_K' # y variable over which to iterate for exploreograms. Options are as above. - # Options for z variables: "CMR2mean", "D_km", "dzIceI_km", "dzIceI_km", "dzClath_km", "dzIceIII_km", "dzIceIIIund_km", - # "dzIceV_km", "dzIceVund_km", "dzIceVI_km", "dzWetHPs_km", "eLid_km", "phiSeafloor_frac", "Rcore_km", "rhoSilMean_kgm3", - # "sigmaMean_Sm", "silPhiCalc_frac", "zb_km", "zSeafloor_km", "h_love_number", "k_love_number", "l_love_number", "qSurf_Wm2" (only if Do.NO_H2O is False). + # Options for z variables: "CMR2mean", "D_km", "Dconv_m", "dzIceI_km", "dzClath_km", "dzIceIII_km", "dzIceIIIund_km", + # "dzIceV_km", "dzIceVund_km", "dzIceVI_km", "dzWetHPs_km", "eLid_km", "phiSeafloor_frac", "Rcore_km", "rhoSilMean_kgm3", "rhoCoreMean_kgm3", + # "sigmaMean_Sm", "silPhiCalc_frac", "zb_km", "zSeafloor_km", "qSurf_Wm2" (only if Do.NO_H2O is False), + # "hLoveAmp", "kLoveAmp", "lLoveAmp", "deltaLoveAmp", "hLovePhase", "kLovePhase", "lLovePhase", "deltaLovePhase". + # 'InductionAmp', 'InductionPhase', 'InductionrBi1Tot_nT', 'InductioniBi1Tot_nT', 'InductionrBi1x_nT', 'InductionrBi1y_nT', 'InductionrBi1z_nT', 'InductioniBi1x_nT', 'InductioniBi1y_nT', 'InductioniBi1z_nT' # New options must be added to ExplorationStruct attributes in Main (assign+save+reload) and in defineStructs, and # FigLbls.exploreDescrip, .Label, and .axisLabels in defineStructs. - ExploreParams.zName = ['CMR2calc', 'silPhiCalc_frac', 'phiSeafloor_frac', 'D_km', 'zb_km', 'dzWetHPs_km', 'rhoSilMean_kgm3', 'Pseafloor_MPa', 'zSeafloor_km', 'sigmaMean_Sm'] # heatmap/colorbar/z variable to plot for exploreograms. Options are as above; accepts a list. + ExploreParams.zName = ['CMR2mean', 'silPhiCalc_frac', 'phiSeafloor_frac', 'D_km', 'zb_km', 'dzWetHPs_km', 'rhoSilMean_kgm3', 'Pseafloor_MPa', 'zSeafloor_km', 'sigmaMean_Sm'] # heatmap/colorbar/z variable to plot for exploreograms. Options are as above; accepts a list. ExploreParams.xRange = [10.0, 100.0] # [min, max] values for the x variable above ExploreParams.yRange = [249.0, 272.5] # Same as above for y variable ExploreParams.nx = 30 # Number of points to use in linspace with above x range ExploreParams.ny = 24 # Same as above for y + ExploreParams.contourName = None # Name of variable to use for contours (if None, uses z variable). Allows plotting contours of one variable while coloring by another. # Reference profile settings # Salinities of reference melting curves in ppt @@ -118,8 +131,9 @@ def configAssign(): 'NaCl':[0, 17.5, 35], 'CustomSolution':[0]} Params.nRefRho = 50 # Number of values for plotting reference density curves (sets resolution) Params.PrefOverride_MPa = None # Pressure setting to force refprofile recalc to go to a specific value instead of automatically using the first hydrosphere max - - # SPICE kernels to use + + #Monte Carlo settings + Params.DO_MONTECARLO = False # Whether to evaluate and/or plot a Monte Carlo parameter exploration for the body in question # SPICE kernels to use Params.spiceDir = 'SPICE' Params.spiceTLS = 'naif0012.tls' # Leap-seconds kernel Params.spicePCK = 'pck00010.tpc' # Planetary Constants Kernel from SPICE in order to get body radii diff --git a/READMEs/NonSelfConsistentInputs.md b/READMEs/NonSelfConsistentInputs.md new file mode 100644 index 00000000..bdd9c2fd --- /dev/null +++ b/READMEs/NonSelfConsistentInputs.md @@ -0,0 +1,20 @@ +Ice Shell Thickness Planet.zbI_km +Ocean Thickness Planet.D_km +Ocean Mean Density Planet.Ocean.rhoMean_kgm3 +Ice Conductive Mean Density Planet.Ocean.rhoCondMean_kgm3['Ih'] +Ice Convective Mean Density Planet.Ocean.rhoConvMean_kgm3['Ih'] +Ocean Thermal Conductivity Planet.Ocean.kThermWater_WmK +Ice Thermal Conductivity Planet.Ocean.kThermIce_WmK['Ih'] +Silicate Mean Density Planet.Sil.rhoMean_kgm3 +Silicate Thermal Conductivity Planet.Sil.kTherm_WmK +Core Radius Planet.Core.Rmean_m +Core Mean Density Planet.Core.rhoMean_kgm3 +Core Thermal Conductivity Planet.Core.kTherm_WmK +Silicate Viscosity Planet.Sil.etaRock_Pas +Silicate Shear Modulus Planet.Sil.GSmean_GPa +Ice Conducting Shear Modulus Planet.Ocean.GScondMean_GPa +Melting Point Ice Viscosity Planet.etaMelt_Pas +Surface Temperature Planet.Bulk.Tsurf_K +Creep Activation Energy Planet.Ocean.Eact_kJmol +Andrade Exponent Planet.Gravity.andradExponent +Ice Convecting Shear Modulus Planet.Ocean.GSconvMean_GPa