• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

p-ortega / mf6rtm / 25156475946

25 Mar 2026 07:32AM UTC coverage: 83.028%. Remained the same
25156475946

push

github

web-flow
Merge pull request #57 from p-ortega/develop

Develop - migrated to pixi,  removed unused local deps, improves cmd run

19 of 35 new or added lines in 4 files covered. (54.29%)

46 existing lines in 2 files now uncovered.

1448 of 1744 relevant lines covered (83.03%)

0.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

81.7
/mf6rtm/utils/utils.py
1
import platform
1✔
2
import os
1✔
3
import shutil
1✔
4
import pandas as pd
1✔
5
import numpy as np
1✔
6

7
# global variables
8
endmainblock = """\nPRINT
1✔
9
    -reset false
10
END\n"""
11

12
def flatten_list(xss):
1✔
13
    """Flatten a list of lists"""
14
    return [x for xs in xss for x in xs]
1✔
15

16
def concentration_l_to_m3(x):
1✔
17
    """Convert M/L to M/m3"""
18
    c = x * 1e3
1✔
19
    return c
1✔
20

21
def concentration_m3_to_l(x):
1✔
22
    """Convert M/L to M/m3"""
23
    c = x * 1e-3
1✔
24
    return c
1✔
25

26
def concentration_to_massrate(q, conc):
1✔
27
    """Calculate mass rate from rate (L3/T) and concentration (M/L3)"""
28
    mrate = q * conc  # M/T
1✔
29
    return mrate
1✔
30

31
def concentration_volbulk_to_volwater(conc_volbulk, porosity):
1✔
32
    """Calculate concentrations as volume of pore water from bulk volume and porosity"""
33
    conc_volwater = conc_volbulk * (1 / porosity)
1✔
34
    return conc_volwater
1✔
35

36
def add_charge_flag_to_species_in_solution(script: str, species: list[str] = ["pH"]) -> str:
1✔
37
    """Add 'charge' to species lines inside SOLUTION blocks in a PHREEQC input string."""
38
    lines = script.splitlines()
1✔
39
    modified_lines = []
1✔
40

41
    inside_solution = False
1✔
42
    for line in lines:
1✔
43
        stripped = line.strip()
1✔
44

45
        if stripped.upper().startswith("SOLUTION"):
1✔
46
            inside_solution = True
1✔
47
        elif stripped.upper().startswith("END"):
1✔
48
            inside_solution = False
1✔
49

50
        # Only modify if we're inside a SOLUTION block
51
        if inside_solution:
1✔
52
            for target in species:
1✔
53
                if stripped.startswith(target):
1✔
54
                    if "charge" not in stripped:
1✔
55
                        line = line.rstrip() + " charge"
1✔
56
                    break
1✔
57

58
        modified_lines.append(line)
1✔
59

60
    return "\n".join(modified_lines)
1✔
61

62
def solution_csv_to_dict(csv_file, header=True):
1✔
63
    """Read a solution CSV file and convert it to a dictionary
64
    Parameters
65
    ----------
66
    csv_file : str
67
        The path to the solution CSV file.
68
    header : bool, optional
69
        Whether the CSV file has a header. The default is True.
70
    Returns
71
    -------
72
    data : dict
73
        A dictionary with the first column as keys and the remaining columns as values.
74
    """
75
    # Read the CSV file and convert it to a dictionary using first row as keys and columns as value (array of shape ncol)
76
    import csv
1✔
77

78
    with open(csv_file, mode="r") as infile:
1✔
79
        reader = csv.reader(infile)
1✔
80
        # skip header assuming first line is header
81
        if header:
1✔
82
            next(reader)
1✔
83

84
        data = {
1✔
85
            rows[0]: [float(i) for i in rows[1:]]
86
            for rows in reader
87
            if not rows[0].startswith("#")
88
        }
89
        # data = {rows[0]: rows[1:] for rows in reader if rows[0].startswith('#') == False}
90

91
        # for key, value in data.items():
92
        #     data[key] = [float(i) for i in value]
93
    return data
1✔
94

95
# def kinetics_df_to_dict(data, header=True):
96
#     """Read a kinetics CSV file and convert it to a dictionary
97
#     Parameters
98
#     ----------
99
#     csv_file : str
100
#         The path to the kinetics CSV file.
101
#     header : bool, optional
102
#         Whether the CSV file has a header. The default is True.
103
#     Returns
104
#     -------
105
#     data : dict
106
#         A dictionary with the first column as keys and the remaining columns as values.
107
#     """
108
#     dic = {}
109
#     # data.set_index(data.columns[0], inplace=True)
110
#     par_cols = [col for col in data.columns if col.startswith("par")]
111
#     for key in data.index:
112
#         parms = [item for item in data.loc[key, par_cols] if not pd.isna(item)]
113
#         # print(parms)
114
#         dic[key] = [
115
#             item
116
#             for item in data.loc[key]
117
#             if item not in parms and not pd.isna(item)
118
#         ]
119
#         dic[key].append(parms)
120
#     return dic
121

122

123
def solution_df_to_dict(data, header=True):
1✔
124
    """Convert a pandas DataFrame to a dictionary
125
    Parameters
126
    ----------
127
    data : pandas.DataFrame
128
        The DataFrame to convert.
129
    header : bool, optional
130
        Whether the DataFrame has a header. The default is True.
131
    Returns
132
    -------
133
    data : dict
134
        A dictionary with the first column as keys and the remaining columns as values.
135
    """
136
    data = data.T.to_dict("list")
1✔
137
    for key, value in data.items():
1✔
138
        data[key] = [float(i) for i in value]
1✔
139
    return data
1✔
140

141
# def equilibrium_phases_csv_to_dict(csv_file, header=True):
142
#     """Read an equilibrium phases CSV file and convert it to a dictionary
143
#     Parameters
144
#     ----------
145
#     csv_file : str
146
#         The path to the equilibrium phases CSV file.
147
#     header : bool, optional
148
#         Whether the CSV file has a header. The default is True.
149
#     Returns
150
#     -------
151
#     data : dict
152
#         A dictionary with phase names as keys and lists of saturation indices and amounts as values.
153
#     """
154
#     df=pd.read_csv(csv_file)
155
#     import csv
156

157
#     with open(csv_file, mode="r") as infile:
158
#         reader = csv.reader(infile)
159
#         # skip header assuming first line is header
160
#         if header:
161
#             next(reader)
162
#         data = {}
163
#         for row in reader:
164
#             if row[0].startswith("#"):
165
#                 continue
166
#             if int(row[-1]) not in data:
167
#                 # data[row[0]] = [[float(row[1]), float(row[2])]]
168
#                 data[int(row[-1])] = {row[0]: [float(row[1]), float(row[2])]}
169
#             else:
170
#                 # data[int(row[-1])] # append {row[0]: [float(row[1]), float(row[2])]} to the existing nested dictionary
171
#                 data[int(row[-1])][row[0]] = [float(row[1]), float(row[2])]
172
#     return data
173

174
def surfaces_csv_to_dict(csv_file, header=True):
1✔
175
    """Read an equilibrium phases CSV file and convert it to a dictionary
176
    Parameters
177
    ----------
178
    csv_file : str
179
        The path to the equilibrium phases CSV file.
180
    header : bool, optional
181
        Whether the CSV file has a header. The default is True.
182
    Returns
183
    -------
184
    data : dict
185
        A dictionary with phase names as keys and lists of saturation indices and amounts as values.
186
    """
187
    import csv
1✔
188

189
    with open(csv_file, mode="r") as infile:
1✔
190
        reader = csv.reader(infile)
1✔
191
        # skip header assuming first line is header
192
        if header:
1✔
193
            next(reader)
1✔
194
        data = {}
1✔
195
        for row in reader:
1✔
196
            if row[0].startswith("#"):
1✔
197
                continue
×
198
            if int(row[-1]) not in data:
1✔
199
                # data[row[0]] = [[float(row[1]), float(row[2])]]
200
                data[int(row[-1])] = {row[0]: [i for i in row[1:-1]]}
1✔
201
            else:
202
                # data[int(row[-1])] # append {row[0]: [float(row[1]), float(row[2])]} to the existing nested dictionary
203
                data[int(row[-1])][row[0]] = [i for i in row[1:-1]]
×
204
    return data
1✔
205

206
def parse_equilibriums_dataframe(df, columns=None):
1✔
207
    """
208
    Convert a DataFrame of equilibrium phases into a nested dictionary.
209

210
    Parameters
211
    ----------
212
    df : pandas.DataFrame
213
        The input DataFrame.
214
    columns : list of str, optional
215
        List of column names in the following order:
216
        [phase, sat_index, conc_mol_lb, num].
217
        Default: ['phase', 'sat_index', 'conc_mol_lb', 'num']
218
        num corresponds to the block number
219

220
    Returns
221
    -------
222
    dict
223
        Dictionary structured as {zone: {phase: {'si': val, 'm0': val}}}
224
    """
225
    if columns is None:
1✔
226
        columns = ['phase', 'sat_index', 'conc_mol_lb', 'num']
1✔
227

228
    phase_col, si_col, moles_col, zone_col = columns
1✔
229

230
    equ_phases = {}
1✔
231
    for _, row in df.iterrows():
1✔
232
        zone = row[zone_col]
1✔
233
        if zone not in equ_phases:
1✔
234
            equ_phases[zone] = {}
1✔
235
        equ_phases[zone][row[phase_col]] = {
1✔
236
            "si": row[si_col],
237
            "m0": row[moles_col],
238
        }
239

240
    return equ_phases
1✔
241

242
def parse_kinetics_dataframe(df, optional_fields=["formula", "steps"]):
1✔
243
    """
244
    Convert a DataFrame of kinetic phase data into a nested dictionary to import into mup3d.
245

246
    Parameters
247
    ----------
248
    df : pandas.DataFrame
249
        Must include 'm0', 'parm1'-'parm4', 'num', and either 'phase' or 'name'.
250
    optional_fields : list of str, optional
251
        List of optional columns to include if present and non-NaN.
252

253
    Returns
254
    -------
255
    dict
256
        Nested dictionary like:
257
        {
258
            1: {
259
                "Calcite": {
260
                    "m0": 4.0,
261
                    "parms": [100.0, 0.6],
262
                    "formula": "Calcite -1.0 Ca 1.0 C -1.0",
263
                    ...
264
                },
265
                ...
266
            }
267
        }
268
    """
269
    df.columns = df.columns.str.strip()
1✔
270

271
    if optional_fields is None:
1✔
272
        optional_fields = ["formula", "steps", "tol", "phase_type"]
×
273

274
    # Find name column
275
    name_col = next((col for col in ["phase", "name"] if col in df.columns), None)
1✔
276
    if name_col is None:
1✔
277
        raise ValueError("Expected a 'phase' or 'name' column in the DataFrame.")
×
278

279
    phases = {}
1✔
280

281
    for _, row in df.iterrows():
1✔
282
        zone = int(row["num"])
1✔
283
        species_name = row[name_col]
1✔
284

285
        if zone not in phases:
1✔
286
            phases[zone] = {}
1✔
287

288
        # Parse m0
289
        m0 = float(row["m0"])
1✔
290

291
        # Parse parms (skip NaN)
292
        parms = [
1✔
293
            float(row[col]) for col in ["parm1", "parm2", "parm3", "parm4"]
294
            if col in df.columns and not pd.isna(row[col])
295
        ]
296

297
        # Build entry
298
        entry = {
1✔
299
            "m0": m0,
300
            "parms": parms
301
        }
302

303
        # Add optional fields
304
        for field in optional_fields:
1✔
305
            if field in df.columns:
1✔
306
                val = row[field]
×
307
                if not pd.isna(val):
×
308
                    entry[field] = val if not isinstance(val, float) else float(val)
×
309

310
        phases[zone][species_name] = entry
1✔
311

312
    return phases
1✔
313

314
# def kinetics_phases_csv_to_dict(csv_file, header=True):
315
#     """Read an kinetic phases CSV file and convert it to a dictionary
316
#     Parameters
317
#     ----------
318
#     csv_file : str
319
#         The path to the equilibrium phases CSV file.
320
#     header : bool, optional
321
#         Whether the CSV file has a header. The default is True.
322
#     Returns
323
#     -------
324
#     data : dict
325
#         A dictionary with phase names as keys and lists of saturation indices and amounts as values.
326
#     """
327
#     df = pd.read(csv_file)
328
#     import csv
329

330
#     with open(csv_file, mode="r") as infile:
331
#         reader = csv.reader(infile)
332
#         # skip header assuming first line is header
333
#         if header:
334
#             cols = next(reader)
335
#         data = {}
336
#         for row in reader:
337
#             if row[0].startswith("#"):
338
#                 continue
339
#             rowcleaned = [i for i in row if i != ""]
340
#             if int(rowcleaned[-1]) not in data:
341
#                 # data[row[0]] = [[float(row[1]), float(row[2])]]
342
#                 data[int(rowcleaned[-1])] = {rowcleaned[0]: [float(rowcleaned[1])]}
343
#                 data[int(rowcleaned[-1])][rowcleaned[0]].append(
344
#                     [float(i) for i in rowcleaned[2:-1]]
345
#                 )
346
#             else:
347
#                 data[int(rowcleaned[-1])][rowcleaned[0]] = [float(rowcleaned[1])]
348
#                 data[int(rowcleaned[-1])][rowcleaned[0]].append(
349
#                     [float(i) for i in rowcleaned[2:-1]]
350
#                 )
351
#                 # [float(i) for i in rowcleaned[1:-1]]
352
#     return data
353

354

355
def handle_block(current_items, block_generator, i, *args, **kwargs):
1✔
356
    """Generate a block for a PHREEQC input script if the current items are not empty
357
    Parameters
358
    ----------
359
    current_items : list
360
        A list of items to include in the block.
361
    block_generator : function
362
        A function that generates the block.
363
    i : int
364
        The block number.
365
    Returns
366
    -------
367
    script : str
368
        The block as a string.
369
    """
370
    # temp = kwargs.get('temp')  # Safely get 'temp' if it exists, else returns None
371
    # water = kwargs.get('water')
372

373
    script = ""
1✔
374
    script += block_generator(current_items, i, *args, **kwargs)
1✔
375
    return script
1✔
376

377
def get_compound_names(database_file, block="SOLUTION_MASTER_SPECIES"):
1✔
378
    """Get a list of compound names from a PHREEQC database file
379
    Parameters
380
    ----------
381
    database_file : str
382
        The path to the PHREEQC database file.
383
    block : str, optional
384
        The keyword for the block containing the compound names. The default is 'SOLUTION_MASTER_SPECIES'.
385
    Returns
386
    -------
387
    compound_names : list
388
        A list of compound names.
389
    """
390
    species_names = []
1✔
391
    with open(database_file, "r", errors="replace") as db:
1✔
392
        lines = db.readlines()
1✔
393
        in_block = False
1✔
394
        for line in lines:
1✔
395
            if block.upper() in line:
1✔
396
                in_block = True
1✔
397
            elif (
1✔
398
                in_block
399
                and line.strip().isupper()
400
                and len(line.strip()) > 1
401
                and "_" in line.strip()
402
            ):  # Stop when encountering the next keyword
403
                in_block = False
1✔
404
            elif in_block:
1✔
405
                if (
1✔
406
                    line.strip()
407
                    and not line.startswith("#")
408
                    and line.split()[0][0].isupper()
409
                ):  # Ignore empty lines and comments
410
                    species = line.split()[
1✔
411
                        0
412
                    ]  # The species name is the first word on the line
413
                    species_names.append(species)
1✔
414
                if (
1✔
415
                    line.strip()
416
                    and not line.startswith("#")
417
                    and line.split()[0][0].isupper()
418
                    and block.startswith("EXCHANGE")
419
                ):
420
                    # Ignore empty lines and comments
421
                    species = line.split()[
1✔
422
                        -1
423
                    ]  # The exchange species are the last word on the line
424
                    species_names.append(species)
1✔
425
    return species_names
1✔
426

427
def map_species_property_to_grid(data_dict, ic_array, species, property_key):
1✔
428
    """
429
    Create an array matching ic_array shape with a selected property
430
    (e.g., 'moles', 'si') of a given species from a zone-indexed dictionary.
431

432
    Parameters
433
    ----------
434
    data_dict : dict
435
        Format: {zone_idx (int): {species_name (str): {key: value, ...}}}
436
    ic_array : np.ndarray
437
        1-indexed zone allocation array of shape (nlay, nrow, ncol).
438
    species : str
439
        Species name to extract (e.g., 'Goethite').
440
    property_key : str
441
        Property to extract for the species (e.g., 'moles', 'si').
442

443
    Returns
444
    -------
445
    np.ndarray
446
        Array of same shape as ic_array filled with the extracted property values.
447

448
    Raises
449
    ------
450
    KeyError
451
        If the species or property_key is missing in any zone where it's expected.
452
    """
453
    output = np.zeros_like(ic_array, dtype=float)
1✔
454

455
    for zone_idx, species_dict in data_dict.items():
1✔
456
        if species not in species_dict:
1✔
457
            raise KeyError(f"Species '{species}' not found in block id {zone_idx}")
×
458
        if property_key not in species_dict[species]:
1✔
459
            raise KeyError(f"Property '{property_key}' not found for species '{species}' in block id {zone_idx}")
×
460

461
        value = species_dict[species][property_key]
1✔
462
        output[ic_array == (zone_idx + 1)] = value
1✔
463

464
    return output
1✔
465

466
# def generate_exchange_block(exchange_dict, i, equilibrate_solutions=[]):
467
#     """Generate an EXCHANGE block for PHREEQC input script
468
#     Parameters
469
#     ----------
470
#     exchange_dict : dict
471
#         A dictionary with species names as keys and exchange concentrations as values.
472
#     i : int
473
#         The block number.
474
#     Returns
475
#     -------
476
#     script : str
477
#         The EXCHANGE block as a string.
478
#     """
479
#     script = f"EXCHANGE {i+1}\n"
480
#     for species, conc in exchange_dict.items():
481
#         script += f"    {species} {conc:.5e}\n"
482
#     if len(equilibrate_solutions) > 0:
483
#         script += f"    -equilibrate {equilibrate_solutions[i]}"
484
#     else:
485
#         script += f"    -equilibrate {1}"
486
#     script += "\nEND\n"
487
#     return script
488

489
def generate_exchange_block(phases_dict, i, equilibrate_solutions=1):
1✔
490
    """Generate an EXCHANGE block for PHREEQC input script
491
    Parameters
492
    ----------
493
    exchange_dict : dict
494
        A dictionary with species names as keys and exchange concentrations as values.
495
    i : int
496
        The block number.
497
    Returns
498
    -------
499
    script : str
500
        The EXCHANGE block as a string.
501
    """
502
    script = ""
1✔
503
    script += f"EXCHANGE {i+1}\n"
1✔
504
    for name, values in phases_dict.items():
1✔
505
        moles = values['m0']
1✔
506
        script += f"    {name} {moles:.5e}\n"
1✔
507
    script += f"    -equilibrate {equilibrate_solutions}\n"
1✔
508
    script += "END\n"
1✔
509
    return script
1✔
510

511
def generate_surface_block(surface_dict, i, options=[]):
1✔
512
    """Generate a SURFACE block for PHREEQC input script
513
    Parameters
514
    ----------
515
    surface_dict : dict
516
        A dictionary with surface names as keys and lists of site densities and site densities as values.
517
    i : int
518
        The block number.
519
    Returns
520
    -------
521
    script : str
522
        The SURFACE block as a string.
523
    """
524
    script = f"SURFACE {i+1}\n"
1✔
525
    for name, values in surface_dict.items():
1✔
526
        script += f"    {name}"
1✔
527
        script += "    " + " ".join(f"{v}" for v in values) + "\n"
1✔
528
        script += f"    -equilibrate {1}\n"  # TODO: make equilibrate a parameter from eq_solutions
1✔
529
        if len(options) > 0:
1✔
530
            for i in range(len(options)):
×
531
                script += f"    -{options[i]}\n"
×
532
            # script += f"    -{options[i]}\n"
533
        # script += f"    -no_edl\n"
534
    script += "END\n"
1✔
535
    return script
1✔
536

537
# def generate_kinetics_block(kinetics_dict, i):
538
#     """Generate a KINETICS block for PHREEQC input script
539
#     Parameters
540
#     ----------
541
#     kinetics_dict : dict
542
#         A dictionary with species names as keys and lists of rate constants and exponents as values.
543
#     i : int
544
#         The block number.
545
#     Returns
546
#     -------
547
#     script : str
548
#         The KINETICS block as a string.
549
#     """
550
#     script = f"KINETICS {i+1}\n"
551
#     options = ["m0", "parms", "formula", "steps"]
552
#     for species, values in kinetics_dict.items():
553
#         script += f"    {species}\n"
554
#         for k in range(len(values)):
555
#             if isinstance(values[k], list):
556
#                 script += (
557
#                     f"        -{options[k]} "
558
#                     + " ".join(f"{parm:.5e}" for parm in values[k])
559
#                     + "\n"
560
#                 )
561
#             elif isinstance(values[k], str):
562
#                 script += f"        -{options[k]} {values[k]}\n"
563
#             else:
564
#                 script += f"        -{options[k]} {values[k]:.5e}\n"
565
#     script += "\nEND\n"
566
#     return script
567

568
def generate_kinetics_block(kinetics_dict, i):
1✔
569
    """
570
    Generate a KINETICS block for PHREEQC input script.
571

572
    Parameters
573
    ----------
574
    kinetics_dict : dict
575
        Dictionary structured like:
576
        {
577
            "Pyrite": {
578
                "m0": [0.04],
579
                "parms": [3.42, 0.0, 0.5, 0.0],
580
                "formula": "Pyrite -1.0 Fe 1.0 S -1.0"
581
            },
582
            ...
583
        }
584

585
    i : int
586
        The block number.
587

588
    Returns
589
    -------
590
    script : str
591
        The KINETICS block as a string.
592
    """
593
    script = f"KINETICS {i+1}\n"
1✔
594
    for species, fields in kinetics_dict.items():
1✔
595
        script += f"    {species}\n"
1✔
596
        for key, val in fields.items():
1✔
597
            if isinstance(val, list):
1✔
598
                script += f"        -{key} " + " ".join(f"{v:.5e}" for v in val) + "\n"
1✔
599
            elif isinstance(val, (int, float)):
1✔
600
                script += f"        -{key} {val:.5e}\n"
1✔
601
            else:  # assume string
602
                script += f"        -{key} {val}\n"
1✔
603
    script += "END\n"
1✔
604
    return script
1✔
605

606
# def generate_phases_block(phases_dict, i):
607
#     """Generate an EQUILIBRIUM_PHASES block for PHREEQC input script
608
#     Parameters
609
#     ----------
610
#     phases_dict : dict
611
#         A dictionary with phase names as keys and lists of saturation indices and amounts as values.
612
#     i : int
613
#         The block number.
614
#     Returns
615
#     -------
616
#     script : str
617
#         The EQUILIBRIUM_PHASES block as a string.
618
#     """
619
#     script = ""
620
#     script += f"EQUILIBRIUM_PHASES {i+1}\n"
621
#     for name, values in phases_dict.items():
622
#         saturation_index, amount = values
623
#         script += f"    {name} {saturation_index:.5e} {amount:.5e}\n"
624
#     script += "\nEND\n"
625
#     return script
626

627
def generate_equ_phases_block(phases_dict, i):
1✔
628
    """Generate an EQUILIBRIUM_PHASES block for PHREEQC input script
629
    Parameters
630
    ----------
631
    phases_dict : dict
632
        A dictionary with phase names as keys and lists of saturation indices and amounts as values.
633
    i : int
634
        The block number.
635
    Returns
636
    -------
637
    script : str
638
        The EQUILIBRIUM_PHASES block as a string.
639
    """
640
    script = ""
1✔
641
    script += f"EQUILIBRIUM_PHASES {i+1}\n"
1✔
642
    for name, values in phases_dict.items():
1✔
643
        saturation_index = values['si']
1✔
644
        moles = values['m0']
1✔
645
        script += f"    {name} {saturation_index:.5e} {moles:.5e}\n"
1✔
646
    script += "END\n"
1✔
647
    return script
1✔
648

649
def generate_solution_block(species_dict, i, temp=25.0, water=1.0):
1✔
650
    """Generate a SOLUTION block for PHREEQC input script
651
    Parameters
652
    ----------
653
    species_dict : dict
654
        A dictionary with species names as keys and concentrations as values.
655
    i : int
656
        The solution number.
657
    temp : float, optional
658
        The temperature of the solution in degrees Celsius. The default is 25.0.
659
    water : float, optional
660
        The mass of water in kg. The default is 1.0.
661
    Returns
662
    -------
663
    script : str
664
        The SOLUTION block as a string.
665
    """
666
    if isinstance(temp, (int, float)):
1✔
667
        t = f"{temp:.1f}"
1✔
668
    elif isinstance(temp, list):
1✔
669
        t = f"{temp[i]}"
1✔
670
    script = f"SOLUTION {i+1}\n"
1✔
671
    script += f"""   units mol/kgw
1✔
672
    water {water}
673
    temp {t}\n"""
674
    for species, concentration in species_dict.items():
1✔
675
        script += f"    {species} {concentration:.5e}\n"
1✔
676
    script += "\nEND\n"
1✔
677
    return script
1✔
678

679

680
def rearrange_copy_blocks(script):
1✔
681
    # Split the script into lines
682
    lines = script.split("\n")
×
683
    copy_blocks = []
×
684
    # end_blocks = []
685
    other_blocks = []
×
686

687
    # Separate the lines into COPY blocks, END blocks, and other blocks
688
    for line in lines:
×
689
        if line.startswith("COPY"):
×
690
            copy_blocks.append(line)
×
691
        else:
692
            other_blocks.append(line)
×
693

694
    # Combine the blocks, putting the COPY blocks at the end and avoiding consecutive END blocks
695
    rearranged_script = []
×
696
    for block in other_blocks + copy_blocks:
×
697
        rearranged_script.append(block)
×
698

699
    # Join the lines back together into a single script string
700
    rearranged_script = "\n".join(rearranged_script)
×
701

702
    return rearranged_script
×
703

704
def prep_bins(dest_path, src_path=os.path.join("bin"), get_only=[], add_platform=True):
1✔
705
    """Copy executables from the source path to the destination path"""
706

NEW
707
    if add_platform:
×
NEW
708
        if "linux" in platform.platform().lower():
×
NEW
709
            bin_path = os.path.join(src_path, "linux")
×
NEW
710
        elif (
×
711
            "darwin" in platform.platform().lower()
712
            or "macos" in platform.platform().lower()
713
        ):
NEW
714
            bin_path = os.path.join(src_path, "mac")
×
715
        else:
NEW
716
            bin_path = os.path.join(src_path, "win")
×
717
    else:
NEW
718
        bin_path = src_path
×
719
    files = os.listdir(bin_path)
×
UNCOV
720
    if len(get_only) > 0:
×
721
        files = [f for f in files if f.split(".")[0] in get_only]
×
722

723
    for f in files:
×
724
        if os.path.exists(os.path.join(dest_path, f)):
×
UNCOV
725
            try:
×
726
                os.remove(os.path.join(dest_path, f))
×
727
            except IOError:
×
728
                continue
×
729
        shutil.copy2(os.path.join(bin_path, f), os.path.join(dest_path, f))
×
730
    return sorted(files)
×
731

732
def get_indices(element, lst):
1✔
733
    return [i for i, x in enumerate(lst) if x == element]
1✔
734

735
def fill_missing_minerals(data_dict):
1✔
736
    """Fill missing minerals in all zones with zero parameters.
737

738
    Parameters
739
    ----------
740
    data_dict : dict
741
        Dictionary with zone indices as keys and mineral dictionaries as values.
742
        Each mineral dictionary contains parameter names and values.
743

744
    Returns
745
    -------
746
    dict
747
        Updated dictionary with all minerals present in all zones.
748

749
    Examples
750
    --------
751
    >>> data_dict = {
752
    ...     0: {'Goethite': {'m0': 1.0, 'si': 0.9}},
753
    ...     1: {'Pyrite': {'m0': 2.0, 'si': 1.8}}
754
    ... }
755
    >>> filled = fill_missing_minerals(data_dict)
756
    >>> filled[0]['Pyrite']  # Now exists with zeros
757
    {'m0': 0.0, 'si': 0.0}
758
    """
759
    # Collect all unique minerals across all zones
760
    all_minerals = set()
1✔
761
    for zone_dict in data_dict.values():
1✔
762
        all_minerals.update(zone_dict.keys())
1✔
763

764
    # Collect all unique parameters for each mineral
765
    mineral_params = {}
1✔
766
    for zone_dict in data_dict.values():
1✔
767
        for mineral, params in zone_dict.items():
1✔
768
            if mineral not in mineral_params:
1✔
769
                mineral_params[mineral] = set(params.keys())
1✔
770
            else:
UNCOV
771
                mineral_params[mineral].update(params.keys())
×
772

773
    # Fill in missing minerals and parameters
774
    for zone_idx in data_dict.keys():
1✔
775
        for mineral in all_minerals:
1✔
776
            if mineral not in data_dict[zone_idx]:
1✔
777
                # Mineral doesn't exist in this zone, add it with zeros
778
                data_dict[zone_idx][mineral] = {
1✔
779
                    param: 0.0 for param in mineral_params[mineral]
780
                }
781
            else:
782
                # Mineral exists, but check if all parameters are present
783
                for param in mineral_params[mineral]:
1✔
784
                    if param not in data_dict[zone_idx][mineral]:
1✔
UNCOV
785
                        data_dict[zone_idx][mineral][param] = 0.0
×
786

787
    return data_dict
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc