LoopStructural 1.6.16__py3-none-any.whl → 1.6.18__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of LoopStructural might be problematic. Click here for more details.

@@ -20,6 +20,7 @@ ch.setLevel(logging.WARNING)
20
20
  loggers = {}
21
21
  from .modelling.core.geological_model import GeologicalModel
22
22
  from .modelling.core.stratigraphic_column import StratigraphicColumn
23
+ from .modelling.core.fault_topology import FaultTopology
23
24
  from .interpolators._api import LoopInterpolator
24
25
  from .interpolators import InterpolatorBuilder
25
26
  from .datatypes import BoundingBox
@@ -166,8 +166,7 @@ class GeologicalInterpolator(metaclass=ABCMeta):
166
166
  """
167
167
  if points.shape[1] == self.dimensions * 2:
168
168
  points = np.hstack([points, np.ones((points.shape[0], 1))])
169
- logger.warning(f"No weight provided for normal constraints, all weights are set to 1")
170
- raise Warning
169
+ logger.info("No weight provided for normal constraints, all weights are set to 1")
171
170
  if points.shape[1] < self.dimensions * 2 + 1:
172
171
  raise ValueError("Normal constraints must at least have X,Y,Z,nx,ny,nz")
173
172
  self.n_n = points.shape[0]
@@ -0,0 +1,234 @@
1
+ from ..features.fault import FaultSegment
2
+ from ...utils import Observable
3
+ from .stratigraphic_column import StratigraphicColumn
4
+ import enum
5
+ import numpy as np
6
+ class FaultRelationshipType(enum.Enum):
7
+ ABUTTING = "abutting"
8
+ FAULTED = "faulted"
9
+ NONE = "none"
10
+
11
+ class FaultTopology(Observable['FaultTopology']):
12
+ """A graph representation of the relationships between faults and the
13
+ relationship with stratigraphic units.
14
+ """
15
+ def __init__(self, stratigraphic_column: 'StratigraphicColumn'):
16
+ super().__init__()
17
+ self.faults = []
18
+ self.stratigraphic_column = stratigraphic_column
19
+ self.adjacency = {}
20
+ self.stratigraphy_fault_relationships = {}
21
+ def add_fault(self, fault: FaultSegment):
22
+ """
23
+ Adds a fault to the fault topology.
24
+ """
25
+ if not isinstance(fault, str):
26
+ raise TypeError("Expected a fault name.")
27
+
28
+ self.faults.append(fault)
29
+ self.notify('fault_added', fault=fault)
30
+
31
+ def remove_fault(self, fault: str):
32
+ """
33
+ Removes a fault from the fault topology.
34
+ """
35
+ if fault not in self.faults:
36
+ raise ValueError(f"Fault {fault} not found in the topology.")
37
+
38
+ self.faults.remove(fault)
39
+ # Remove any relationships involving this fault
40
+ self.adjacency = {k: v for k, v in self.adjacency.items() if fault not in k}
41
+ self.stratigraphy_fault_relationships = {
42
+ k: v for k, v in self.stratigraphy_fault_relationships.items() if k[1] != fault
43
+ }
44
+ self.notify('fault_removed', fault=fault)
45
+
46
+ def add_abutting_relationship(self, fault_name: str, abutting_fault: str):
47
+ """
48
+ Adds an abutting relationship between two faults.
49
+ """
50
+ if fault_name not in self.faults or abutting_fault not in self.faults:
51
+ raise ValueError("Both faults must be part of the fault topology.")
52
+
53
+ if fault_name not in self.adjacency:
54
+ self.adjacency[fault_name] = []
55
+
56
+ self.adjacency[(fault_name, abutting_fault)] = FaultRelationshipType.ABUTTING
57
+ self.notify('abutting_relationship_added', {'fault': fault_name, 'abutting_fault': abutting_fault})
58
+ def add_stratigraphy_fault_relationship(self, unit_name:str, fault_name: str):
59
+ """
60
+ Adds a relationship between a stratigraphic unit and a fault.
61
+ """
62
+ if fault_name not in self.faults:
63
+ raise ValueError("Fault must be part of the fault topology.")
64
+
65
+ if unit_name is None:
66
+ raise ValueError(f"No stratigraphic group found for unit name: {unit_name}")
67
+ self.stratigraphy_fault_relationships[(unit_name,fault_name)] = True
68
+
69
+ self.notify('stratigraphy_fault_relationship_added', {'unit': unit_name, 'fault': fault_name})
70
+ def add_faulted_relationship(self, fault_name: str, faulted_fault_name: str):
71
+ """
72
+ Adds a faulted relationship between two faults.
73
+ """
74
+ if fault_name not in self.faults or faulted_fault_name not in self.faults:
75
+ raise ValueError("Both faults must be part of the fault topology.")
76
+
77
+ if fault_name not in self.adjacency:
78
+ self.adjacency[fault_name] = []
79
+
80
+ self.adjacency[(fault_name, faulted_fault_name)] = FaultRelationshipType.FAULTED
81
+ self.notify('faulted_relationship_added', {'fault': fault_name, 'faulted_fault': faulted_fault_name})
82
+ def remove_fault_relationship(self, fault_name: str, related_fault_name: str):
83
+ """
84
+ Removes a relationship between two faults.
85
+ """
86
+ if (fault_name, related_fault_name) in self.adjacency:
87
+ del self.adjacency[(fault_name, related_fault_name)]
88
+ elif (related_fault_name, fault_name) in self.adjacency:
89
+ del self.adjacency[(related_fault_name, fault_name)]
90
+ else:
91
+ raise ValueError(f"No relationship found between {fault_name} and {related_fault_name}.")
92
+ self.notify('fault_relationship_removed', {'fault': fault_name, 'related_fault': related_fault_name})
93
+ def update_fault_relationship(self, fault_name: str, related_fault_name: str, new_relationship_type: FaultRelationshipType):
94
+ if new_relationship_type == FaultRelationshipType.NONE:
95
+ self.adjacency.pop((fault_name, related_fault_name), None)
96
+ else:
97
+ self.adjacency[(fault_name, related_fault_name)] = new_relationship_type
98
+ self.notify('fault_relationship_updated', {'fault': fault_name, 'related_fault': related_fault_name, 'new_relationship_type': new_relationship_type})
99
+ def change_relationship_type(self, fault_name: str, related_fault_name: str, new_relationship_type: FaultRelationshipType):
100
+ """
101
+ Changes the relationship type between two faults.
102
+ """
103
+ if (fault_name, related_fault_name) in self.adjacency:
104
+ self.adjacency[(fault_name, related_fault_name)] = new_relationship_type
105
+
106
+ else:
107
+ raise ValueError(f"No relationship found between {fault_name} and {related_fault_name}.")
108
+ self.notify('relationship_type_changed', {'fault': fault_name, 'related_fault': related_fault_name, 'new_relationship_type': new_relationship_type})
109
+ def get_fault_relationships(self, fault_name: str):
110
+ """
111
+ Returns a list of relationships for a given fault.
112
+ """
113
+ relationships = []
114
+ for (f1, f2), relationship_type in self.adjacency.items():
115
+ if f1 == fault_name or f2 == fault_name:
116
+ relationships.append((f1, f2, relationship_type))
117
+ return relationships
118
+ def get_fault_relationship(self, fault_name: str, related_fault_name: str):
119
+ """
120
+ Returns the relationship type between two faults.
121
+ """
122
+ return self.adjacency.get((fault_name, related_fault_name), FaultRelationshipType.NONE)
123
+ def get_faults(self):
124
+ """
125
+ Returns a list of all faults in the topology.
126
+ """
127
+ return self.faults
128
+
129
+ def get_stratigraphy_fault_relationships(self):
130
+ """
131
+ Returns a dictionary of stratigraphic unit to fault relationships.
132
+ """
133
+ return self.stratigraphy_fault_relationships
134
+ def get_fault_stratigraphic_unit_relationships(self):
135
+ units_group_pairs = self.stratigraphic_column.get_group_unit_pairs()
136
+ matrix = np.zeros((len(self.faults), len(units_group_pairs)), dtype=int)
137
+ for i, fault in enumerate(self.faults):
138
+ for j, (unit_name, _group) in enumerate(units_group_pairs):
139
+ if (unit_name, fault) in self.stratigraphy_fault_relationships:
140
+ matrix[i, j] = 1
141
+
142
+ return matrix
143
+ def get_fault_stratigraphic_relationship(self, unit_name: str, fault:str) -> bool:
144
+ """
145
+ Returns a dictionary of fault to stratigraphic unit relationships.
146
+ """
147
+ if unit_name is None:
148
+ raise ValueError(f"No stratigraphic group found for unit name: {unit_name}")
149
+ if (unit_name, fault) not in self.stratigraphy_fault_relationships:
150
+ return False
151
+ return self.stratigraphy_fault_relationships[(unit_name, fault)]
152
+
153
+ def update_fault_stratigraphy_relationship(self, unit_name: str, fault_name: str, flag: bool = True):
154
+ """
155
+ Updates the relationship between a stratigraphic unit and a fault.
156
+ """
157
+ if not flag:
158
+ if (unit_name, fault_name) in self.stratigraphy_fault_relationships:
159
+ del self.stratigraphy_fault_relationships[(unit_name, fault_name)]
160
+ else:
161
+ self.stratigraphy_fault_relationships[(unit_name, fault_name)] = flag
162
+
163
+ self.notify('stratigraphy_fault_relationship_updated', {'unit': unit_name, 'fault': fault_name})
164
+
165
+ def remove_fault_stratigraphy_relationship(self, unit_name: str, fault_name: str):
166
+ """
167
+ Removes a relationship between a stratigraphic unit and a fault.
168
+ """
169
+ if (unit_name, fault_name) not in self.stratigraphy_fault_relationships:
170
+ raise ValueError(f"No relationship found between unit {unit_name} and fault {fault_name}.")
171
+ else:
172
+ self.stratigraphy_fault_relationships.pop((unit_name, fault_name), None)
173
+
174
+ self.notify('stratigraphy_fault_relationship_removed', {'unit': unit_name, 'fault': fault_name})
175
+ def get_matrix(self):
176
+ """
177
+ Returns a matrix representation of the fault relationships.
178
+ """
179
+ matrix = np.zeros((len(self.faults), len(self.faults)), dtype=int)
180
+ for (fault_name, related_fault_name), relationship_type in self.adjacency.items():
181
+ fault_index = self.faults.index(next(f for f in self.faults if f == fault_name))
182
+ related_fault_index = self.faults.index(next(f for f in self.faults if f == related_fault_name))
183
+ if relationship_type == FaultRelationshipType.ABUTTING:
184
+ matrix[fault_index, related_fault_index] = 1
185
+ elif relationship_type == FaultRelationshipType.FAULTED:
186
+ matrix[fault_index, related_fault_index] = 2
187
+ return matrix
188
+
189
+ def to_dict(self):
190
+ """
191
+ Returns a dictionary representation of the fault topology.
192
+ """
193
+ return {
194
+ "faults": self.faults,
195
+ "adjacency": self.adjacency,
196
+ "stratigraphy_fault_relationships": self.stratigraphy_fault_relationships,
197
+ }
198
+
199
+ def update_from_dict(self, data):
200
+ """
201
+ Updates the fault topology from a dictionary representation.
202
+ """
203
+ with self.freeze_notifications():
204
+ self.faults.extend(data.get("faults", []))
205
+ adjacency = data.get("adjacency", {})
206
+ stratigraphy_fault_relationships = data.get("stratigraphy_fault_relationships", {})
207
+ for (fault,abutting_fault) in adjacency.values():
208
+ if fault not in self.faults:
209
+ self.add_fault(fault)
210
+ if abutting_fault not in self.faults:
211
+ self.add_fault(abutting_fault)
212
+ self.add_abutting_relationship(fault, abutting_fault)
213
+ for unit_name, fault_names in stratigraphy_fault_relationships.items():
214
+ for fault_name in fault_names:
215
+ if fault_name not in self.faults:
216
+ self.add_fault(fault_name)
217
+ self.add_stratigraphy_fault_relationship(unit_name, fault_name)
218
+
219
+ @classmethod
220
+ def from_dict(cls, data):
221
+ """
222
+ Creates a FaultTopology instance from a dictionary representation.
223
+ """
224
+ from .stratigraphic_column import StratigraphicColumn
225
+ stratigraphic_column = data.get("stratigraphic_column",None)
226
+ if not isinstance(stratigraphic_column, StratigraphicColumn):
227
+ if isinstance(stratigraphic_column, dict):
228
+ stratigraphic_column = StratigraphicColumn.from_dict(stratigraphic_column)
229
+ elif not isinstance(stratigraphic_column, StratigraphicColumn):
230
+ raise TypeError("Expected 'stratigraphic_column' to be a StratigraphicColumn instance or dict.")
231
+
232
+ topology = cls(stratigraphic_column)
233
+ topology.update_from_dict(data)
234
+ return topology
@@ -6,7 +6,7 @@ from ...utils import getLogger
6
6
 
7
7
  import numpy as np
8
8
  import pandas as pd
9
- from typing import List, Optional
9
+ from typing import List, Optional, Union, Dict
10
10
  import pathlib
11
11
  from ...modelling.features.fault import FaultSegment
12
12
 
@@ -123,8 +123,7 @@ class GeologicalModel:
123
123
  self.feature_name_index = {}
124
124
  self._data = pd.DataFrame() # None
125
125
 
126
- self.stratigraphic_column = StratigraphicColumn()
127
-
126
+ self._stratigraphic_column = StratigraphicColumn()
128
127
 
129
128
  self.tol = 1e-10 * np.max(self.bounding_box.maximum - self.bounding_box.origin)
130
129
  self._dtm = None
@@ -187,7 +186,6 @@ class GeologicalModel:
187
186
  ].astype(float)
188
187
  return data
189
188
 
190
-
191
189
  if "type" in data:
192
190
  logger.warning("'type' is deprecated replace with 'feature_name' \n")
193
191
  data.rename(columns={"type": "feature_name"}, inplace=True)
@@ -409,7 +407,6 @@ class GeologicalModel:
409
407
  """
410
408
  return [f.name for f in self.faults]
411
409
 
412
-
413
410
  def to_file(self, file):
414
411
  """Save a model to a pickle file requires dill
415
412
 
@@ -506,10 +503,34 @@ class GeologicalModel:
506
503
  self._data = data.copy()
507
504
  # self._data[['X','Y','Z']] = self.bounding_box.project(self._data[['X','Y','Z']].to_numpy())
508
505
 
509
-
510
506
  def set_model_data(self, data):
511
507
  logger.warning("deprecated method. Model data can now be set using the data attribute")
512
508
  self.data = data.copy()
509
+ @property
510
+ def stratigraphic_column(self):
511
+ """Get the stratigraphic column of the model
512
+
513
+ Returns
514
+ -------
515
+ StratigraphicColumn
516
+ the stratigraphic column of the model
517
+ """
518
+ return self._stratigraphic_column
519
+ @stratigraphic_column.setter
520
+ def stratigraphic_column(self, stratigraphic_column: Union[StratigraphicColumn,Dict]):
521
+ """Set the stratigraphic column of the model
522
+
523
+ Parameters
524
+ ----------
525
+ stratigraphic_column : StratigraphicColumn
526
+ the stratigraphic column to set
527
+ """
528
+ if isinstance(stratigraphic_column, dict):
529
+ self.set_stratigraphic_column(stratigraphic_column)
530
+ return
531
+ elif not isinstance(stratigraphic_column, StratigraphicColumn):
532
+ raise ValueError("stratigraphic_column must be a StratigraphicColumn object")
533
+ self._stratigraphic_column = stratigraphic_column
513
534
 
514
535
  def set_stratigraphic_column(self, stratigraphic_column, cmap="tab20"):
515
536
  """
@@ -539,14 +560,14 @@ class GeologicalModel:
539
560
  DeprecationWarning(
540
561
  "set_stratigraphic_column is deprecated, use model.stratigraphic_column.add_units instead"
541
562
  )
542
- for g in stratigraphic_column.keys():
563
+ for i, g in enumerate(stratigraphic_column.keys()):
543
564
  for u in stratigraphic_column[g].keys():
544
565
  thickness = 0
545
566
  if "min" in stratigraphic_column[g][u] and "max" in stratigraphic_column[g][u]:
546
567
  min_val = stratigraphic_column[g][u]["min"]
547
568
  max_val = stratigraphic_column[g][u].get("max", None)
548
569
  thickness = max_val - min_val if max_val is not None else None
549
- logger.warning(
570
+ logger.info(
550
571
  f"""
551
572
  model.stratigraphic_column.add_unit({u},
552
573
  colour={stratigraphic_column[g][u].get("colour", None)},
@@ -557,9 +578,11 @@ class GeologicalModel:
557
578
  colour=stratigraphic_column[g][u].get("colour", None),
558
579
  thickness=thickness,
559
580
  )
581
+
560
582
  self.stratigraphic_column.add_unconformity(
561
583
  name=''.join([g, 'unconformity']),
562
584
  )
585
+ self.stratigraphic_column.group_mapping[f'Group_{i+1}'] = g
563
586
 
564
587
  def create_and_add_foliation(
565
588
  self,
@@ -1400,7 +1423,6 @@ class GeologicalModel:
1400
1423
 
1401
1424
  return self.bounding_box.reproject(points, inplace=inplace)
1402
1425
 
1403
-
1404
1426
  # TODO move scale to bounding box/transformer
1405
1427
  def scale(self, points: np.ndarray, *, inplace: bool = False) -> np.ndarray:
1406
1428
  """Take points in UTM coordinates and reproject
@@ -1419,7 +1441,6 @@ class GeologicalModel:
1419
1441
  """
1420
1442
  return self.bounding_box.project(np.array(points).astype(float), inplace=inplace)
1421
1443
 
1422
-
1423
1444
  def regular_grid(self, *, nsteps=None, shuffle=True, rescale=False, order="C"):
1424
1445
  """
1425
1446
  Return a regular grid within the model bounding box
@@ -1494,22 +1515,18 @@ class GeologicalModel:
1494
1515
  if self.stratigraphic_column is None:
1495
1516
  logger.warning("No stratigraphic column defined")
1496
1517
  return strat_id
1497
- for group in reversed(self.stratigraphic_column.keys()):
1498
- if group == "faults":
1499
- continue
1500
- feature_id = self.feature_name_index.get(group, -1)
1518
+
1519
+ s_id = 0
1520
+ for g in reversed(self.stratigraphic_column.get_groups()):
1521
+ feature_id = self.feature_name_index.get(g.name, -1)
1501
1522
  if feature_id >= 0:
1502
- feature = self.features[feature_id]
1503
- vals = feature.evaluate_value(xyz)
1504
- for series in self.stratigraphic_column[group].values():
1505
- strat_id[
1506
- np.logical_and(
1507
- vals < series.get("max", feature.max()),
1508
- vals > series.get("min", feature.min()),
1509
- )
1510
- ] = series["id"]
1523
+ vals = self.features[feature_id].evaluate_value(xyz)
1524
+ for u in g.units:
1525
+ strat_id[np.logical_and(vals < u.max(), vals > u.min())] = s_id
1526
+ s_id += 1
1511
1527
  if feature_id == -1:
1512
- logger.error(f"Model does not contain {group}")
1528
+ logger.error(f"Model does not contain {g.name}")
1529
+
1513
1530
  return strat_id
1514
1531
 
1515
1532
  def evaluate_model_gradient(self, points: np.ndarray, *, scale: bool = True) -> np.ndarray:
@@ -1710,16 +1727,8 @@ class GeologicalModel:
1710
1727
  ids : list
1711
1728
  list of unique stratigraphic ids, featurename, unit name and min and max scalar values
1712
1729
  """
1713
- ids = []
1714
- if self.stratigraphic_column is None:
1715
- logger.warning('No stratigraphic column defined')
1716
- return ids
1717
- for group in self.stratigraphic_column.keys():
1718
- if group == "faults":
1719
- continue
1720
- for name, series in self.stratigraphic_column[group].items():
1721
- ids.append([series["id"], group, name, series['min'], series['max']])
1722
- return ids
1730
+ return self.stratigraphic_column.get_stratigraphic_ids()
1731
+
1723
1732
 
1724
1733
  def get_fault_surfaces(self, faults: List[str] = []):
1725
1734
  surfaces = []
@@ -1,8 +1,7 @@
1
1
  import enum
2
- from typing import Dict
2
+ from typing import Dict, Optional, List, Tuple
3
3
  import numpy as np
4
- from LoopStructural.utils import rng, getLogger
5
-
4
+ from LoopStructural.utils import rng, getLogger, Observable
6
5
  logger = getLogger(__name__)
7
6
  logger.info("Imported LoopStructural Stratigraphic Column module")
8
7
  class UnconformityType(enum.Enum):
@@ -40,24 +39,59 @@ class StratigraphicColumnElement:
40
39
  self.uuid = uuid
41
40
 
42
41
 
43
- class StratigraphicUnit(StratigraphicColumnElement):
42
+ class StratigraphicUnit(StratigraphicColumnElement, Observable['StratigraphicUnit']):
44
43
  """
45
44
  A class to represent a stratigraphic unit.
46
45
  """
47
46
 
48
- def __init__(self, *, uuid=None, name=None, colour=None, thickness=None, data=None):
47
+ def __init__(self, *, uuid=None, name=None, colour=None, thickness=None, data=None, id=None):
49
48
  """
50
49
  Initializes the StratigraphicUnit with a name and an optional description.
51
50
  """
52
- super().__init__(uuid)
51
+ StratigraphicColumnElement.__init__(self, uuid)
52
+ Observable.__init__(self)
53
53
  self.name = name
54
54
  if colour is None:
55
55
  colour = rng.random(3)
56
56
  self.colour = colour
57
- self.thickness = thickness
57
+ self._thickness = thickness
58
58
  self.data = data
59
59
  self.element_type = StratigraphicColumnElementType.UNIT
60
-
60
+ self._id = id
61
+ self.min_value = None # Minimum scalar field value for the unit
62
+ self.max_value = None # Maximum scalar field value for the unit
63
+ @property
64
+ def id(self):
65
+ return self._id
66
+ @property
67
+ def thickness(self):
68
+ return self._thickness
69
+ @thickness.setter
70
+ def thickness(self, value):
71
+ """
72
+ Sets the thickness of the unit.
73
+ """
74
+ self._thickness = value
75
+ self.notify('unit/thickness_updated', unit=self)
76
+ @id.setter
77
+ def id(self, value):
78
+ """
79
+ Sets the ID of the unit.
80
+ """
81
+ if not isinstance(value, int):
82
+ raise TypeError("ID must be an integer")
83
+ self._id = value
84
+ self.notify('unit/id_updated', unit=self)
85
+ def min(self):
86
+ """
87
+ Returns the minimum value of the unit.
88
+ """
89
+ return self.min_value if self.min_value is not None else 0
90
+ def max(self):
91
+ """
92
+ Returns the maximum value of the unit.
93
+ """
94
+ return self.max_value if self.max_value is not None else np.inf
61
95
  def to_dict(self):
62
96
  """
63
97
  Converts the stratigraphic unit to a dictionary representation.
@@ -154,7 +188,7 @@ class StratigraphicGroup:
154
188
  self.units = units if units is not None else []
155
189
 
156
190
 
157
- class StratigraphicColumn:
191
+ class StratigraphicColumn(Observable['StratigraphicColumn']):
158
192
  """
159
193
  A class to represent a stratigraphic column, which is a vertical section of the Earth's crust
160
194
  showing the sequence of rock layers and their relationships.
@@ -164,20 +198,38 @@ class StratigraphicColumn:
164
198
  """
165
199
  Initializes the StratigraphicColumn with a name and a list of layers.
166
200
  """
167
- self.order = [StratigraphicUnit(name='Basement', colour='grey', thickness=np.inf),StratigraphicUnconformity(name='Base Unconformity', unconformity_type=UnconformityType.ERODE)]
201
+ super().__init__()
202
+ self.order = []
203
+ self.add_basement()
168
204
  self.group_mapping = {}
169
- def clear(self,basement=True):
205
+
206
+ def get_new_id(self):
207
+ """
208
+ Generates a new unique ID for a stratigraphic unit.
209
+ """
210
+ if not self.order:
211
+ return 0
212
+ return max([u.id for u in self.order if isinstance(u, StratigraphicUnit)], default=0) + 1
213
+ def add_basement(self):
214
+ self.add_unit(name='Basement', colour='grey', thickness=np.inf)
215
+ self.add_unconformity(
216
+ name='Base Unconformity', unconformity_type=UnconformityType.ERODE
217
+ )
218
+ def clear(self, basement=True):
170
219
  """
171
220
  Clears the stratigraphic column, removing all elements.
172
221
  """
173
222
  if basement:
174
- self.order = [StratigraphicUnit(name='Basement', colour='grey', thickness=np.inf),StratigraphicUnconformity(name='Base Unconformity', unconformity_type=UnconformityType.ERODE)]
223
+ self.add_basement()
224
+
175
225
  else:
176
226
  self.order = []
177
227
  self.group_mapping = {}
178
-
179
- def add_unit(self, name,*, colour=None, thickness=None, where='top'):
180
- unit = StratigraphicUnit(name=name, colour=colour, thickness=thickness)
228
+ self.notify('column_cleared')
229
+ def add_unit(self, name,*, colour=None, thickness=None, where='top',id=None):
230
+ if id is None:
231
+ id = self.get_new_id()
232
+ unit = StratigraphicUnit(name=name, colour=colour, thickness=thickness, id=id)
181
233
 
182
234
  if where == 'top':
183
235
  self.order.append(unit)
@@ -185,7 +237,9 @@ class StratigraphicColumn:
185
237
  self.order.insert(0, unit)
186
238
  else:
187
239
  raise ValueError("Invalid 'where' argument. Use 'top' or 'bottom'.")
188
-
240
+ unit.attach(self.update_unit_values,'unit/*')
241
+ self.notify('unit_added', unit=unit)
242
+ self.update_unit_values() # Update min and max values after adding a unit
189
243
  return unit
190
244
 
191
245
  def remove_unit(self, uuid):
@@ -195,7 +249,9 @@ class StratigraphicColumn:
195
249
  for i, element in enumerate(self.order):
196
250
  if element.uuid == uuid:
197
251
  del self.order[i]
252
+ self.notify('unit_removed', uuid=uuid)
198
253
  return True
254
+
199
255
  return False
200
256
 
201
257
  def add_unconformity(self, name, *, unconformity_type=UnconformityType.ERODE, where='top' ):
@@ -209,6 +265,7 @@ class StratigraphicColumn:
209
265
  self.order.insert(0, unconformity)
210
266
  else:
211
267
  raise ValueError("Invalid 'where' argument. Use 'top' or 'bottom'.")
268
+ self.notify('unconformity_added', unconformity=unconformity)
212
269
  return unconformity
213
270
 
214
271
  def get_element_by_index(self, index):
@@ -228,6 +285,7 @@ class StratigraphicColumn:
228
285
  return unit
229
286
 
230
287
  return None
288
+
231
289
  def get_unconformity_by_name(self, name):
232
290
  """
233
291
  Retrieves an unconformity by its name from the stratigraphic column.
@@ -245,6 +303,15 @@ class StratigraphicColumn:
245
303
  if element.uuid == uuid:
246
304
  return element
247
305
  raise KeyError(f"No element found with uuid: {uuid}")
306
+
307
+ def get_group_for_unit_name(self, unit_name:str) -> Optional[StratigraphicGroup]:
308
+ """
309
+ Retrieves the group for a given unit name.
310
+ """
311
+ for group in self.get_groups():
312
+ if any(unit.name == unit_name for unit in group.units):
313
+ return group
314
+ return None
248
315
  def add_element(self, element):
249
316
  """
250
317
  Adds a StratigraphicColumnElement to the stratigraphic column.
@@ -287,7 +354,15 @@ class StratigraphicColumn:
287
354
  if group:
288
355
  groups.append(group)
289
356
  return groups
357
+ def get_stratigraphic_ids(self) -> List[List[str]]:
358
+ ids = []
359
+ for group in self.get_groups():
360
+ if group == "faults":
361
+ continue
290
362
 
363
+ for unit in group.units:
364
+ ids.append([unit.id, group, unit.name, unit.min(), unit.max()])
365
+ return ids
291
366
  def get_unitname_groups(self):
292
367
  groups = self.get_groups()
293
368
  groups_list = []
@@ -296,7 +371,18 @@ class StratigraphicColumn:
296
371
  group = [u.name for u in g.units if isinstance(u, StratigraphicUnit)]
297
372
  groups_list.append(group)
298
373
  return groups_list
299
-
374
+
375
+ def get_group_unit_pairs(self) -> List[Tuple[str,str]]:
376
+ """
377
+ Returns a list of tuples containing group names and unit names.
378
+ """
379
+ groups = self.get_groups()
380
+ group_unit_pairs = []
381
+ for g in groups:
382
+ for u in g.units:
383
+ if isinstance(u, StratigraphicUnit):
384
+ group_unit_pairs.append((g.name, u.name))
385
+ return group_unit_pairs
300
386
 
301
387
  def __getitem__(self, uuid):
302
388
  """
@@ -316,6 +402,21 @@ class StratigraphicColumn:
316
402
  self.order = [
317
403
  self.__getitem__(uuid) for uuid in new_order if self.__getitem__(uuid) is not None
318
404
  ]
405
+ self.notify('order_updated', new_order=self.order)
406
+ self.update_unit_values() # Update min and max values after updating the order
407
+ def update_unit_values(self, *, observable: Optional["Observable"] = None, event: Optional[str]= None):
408
+ """
409
+ Updates the min and max values for each unit based on their position in the column.
410
+ """
411
+ # If the event is not 'unit/*', skip the update
412
+ if event is not None and event != 'unit/*':
413
+ return
414
+ cumulative_thickness = 0
415
+ for element in self.order:
416
+ if isinstance(element, StratigraphicUnit):
417
+ element.min_value = cumulative_thickness
418
+ element.max_value = cumulative_thickness + (element.thickness or 0)
419
+ cumulative_thickness = element.max_value
319
420
 
320
421
  def update_element(self, unit_data: Dict):
321
422
  """
@@ -334,6 +435,8 @@ class StratigraphicColumn:
334
435
  element.unconformity_type = UnconformityType(
335
436
  unit_data.get('unconformity_type', element.unconformity_type.value)
336
437
  )
438
+ self.notify('element_updated', element=element)
439
+ self.update_unit_values() # Update min and max values after updating an element
337
440
 
338
441
  def __str__(self):
339
442
  """
@@ -354,14 +457,15 @@ class StratigraphicColumn:
354
457
  """
355
458
  if not isinstance(data, dict):
356
459
  raise TypeError("Data must be a dictionary")
357
- self.clear(basement=False)
358
- elements_data = data.get("elements", [])
359
- for element_data in elements_data:
360
- if "unconformity_type" in element_data:
361
- element = StratigraphicUnconformity.from_dict(element_data)
362
- else:
363
- element = StratigraphicUnit.from_dict(element_data)
364
- self.add_element(element)
460
+ with self.freeze_notifications():
461
+ self.clear(basement=False)
462
+ elements_data = data.get("elements", [])
463
+ for element_data in elements_data:
464
+ if "unconformity_type" in element_data:
465
+ element = StratigraphicUnconformity.from_dict(element_data)
466
+ else:
467
+ element = StratigraphicUnit.from_dict(element_data)
468
+ self.add_element(element)
365
469
  @classmethod
366
470
  def from_dict(cls, data):
367
471
  """
@@ -189,6 +189,9 @@ class SVariogram:
189
189
  # find the extrema of the average curve
190
190
  res = find_peaks_and_troughs(np.array(averagex), np.array(averagey))
191
191
  px2, py2 = res
192
+ logger.info(f"Found {len(px2)} peaks and troughs in the s-variogram")
193
+ for i in range(len(px2)):
194
+ logger.info(f"Peak {i}: {px2[i]} {py2[i]}")
192
195
  wl1 = 0.0
193
196
  wl1py = 0.0
194
197
  for i in range(len(px)):
@@ -38,3 +38,4 @@ rng = np.random.default_rng()
38
38
 
39
39
  from ._surface import LoopIsosurfacer, surface_list
40
40
  from .colours import random_colour, random_hex_colour
41
+ from .observer import Callback, Disposable, Observable
@@ -115,12 +115,17 @@ class LoopIsosurfacer:
115
115
  values,
116
116
  )
117
117
  logger.info(f'Isosurfacing at values: {isovalues}')
118
+ individual_names = False
118
119
  if name is None:
119
120
  names = ["surface"] * len(isovalues)
120
121
  if isinstance(name, str):
121
122
  names = [name] * len(isovalues)
123
+ if len(isovalues) == 1:
124
+ individual_names = True
122
125
  if isinstance(name, list):
123
126
  names = name
127
+ if len(names) == len(isovalues):
128
+ individual_names = True
124
129
  if colours is None:
125
130
  colours = [None] * len(isovalues)
126
131
  for name, isovalue, colour in zip(names, isovalues, colours):
@@ -151,7 +156,7 @@ class LoopIsosurfacer:
151
156
  vertices=verts,
152
157
  triangles=faces,
153
158
  normals=normals,
154
- name=f"{name}_{isovalue}",
159
+ name=name if individual_names else f"{name}_{isovalue}",
155
160
  values=values,
156
161
  colour=colour,
157
162
  )
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from contextlib import contextmanager
5
+ from typing import Any, Generic, Protocol, TypeVar, runtime_checkable
6
+ import threading
7
+ import weakref
8
+
9
+ __all__ = ["Observer", "Observable", "Disposable"]
10
+
11
+
12
+ @runtime_checkable
13
+ class Observer(Protocol):
14
+ """Objects implementing an *update* method can subscribe."""
15
+
16
+ def update(self, observable: "Observable", event: str, *args: Any, **kwargs: Any) -> None:
17
+ """Receive a notification."""
18
+
19
+
20
+ Callback = Callable[["Observable", str, Any], None]
21
+ T = TypeVar("T", bound="Observable")
22
+
23
+
24
+ class Disposable:
25
+ """A small helper that detaches an observer when disposed."""
26
+
27
+ __slots__ = ("_detach",)
28
+
29
+ def __init__(self, detach: Callable[[], None]):
30
+ self._detach = detach
31
+
32
+ def dispose(self) -> None:
33
+ """Detach the associated observer immediately."""
34
+
35
+ self._detach()
36
+
37
+ # Allow use as a context‑manager for temporary subscriptions
38
+ def __enter__(self) -> "Disposable":
39
+ return self
40
+
41
+ def __exit__(self, exc_type, exc, tb):
42
+ self.dispose()
43
+ return False # do not swallow exceptions
44
+
45
+
46
+ class Observable(Generic[T]):
47
+ """Base‑class that provides Observer pattern plumbing."""
48
+
49
+ #: Internal storage: mapping *event* → WeakSet[Callback]
50
+ _observers: dict[str, weakref.WeakSet[Callback]]
51
+ _any_observers: weakref.WeakSet[Callback]
52
+
53
+ def __init__(self) -> None:
54
+ self._lock = threading.RLock()
55
+ self._observers = {}
56
+ self._any_observers = weakref.WeakSet()
57
+ self._frozen = 0
58
+ self._pending: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = []
59
+
60
+ # ‑‑‑ subscription api --------------------------------------------------
61
+ def attach(self, listener: Observer | Callback, event: str | None = None) -> Disposable:
62
+ """Register *listener* for *event* (all events if *event* is None).
63
+
64
+ Returns a :class:`Disposable` so the caller can easily detach again.
65
+ """
66
+ callback: Callback = (
67
+ listener.update # type: ignore[attr‑defined]
68
+ if isinstance(listener, Observer) # type: ignore[misc]
69
+ else listener # already a callable
70
+ )
71
+
72
+ with self._lock:
73
+ if event is None:
74
+ self._any_observers.add(callback)
75
+ else:
76
+ self._observers.setdefault(event, weakref.WeakSet()).add(callback)
77
+
78
+ return Disposable(lambda: self.detach(listener, event))
79
+
80
+ def detach(self, listener: Observer | Callback, event: str | None = None) -> None:
81
+ """Unregister a previously attached *listener*."""
82
+
83
+ callback: Callback = (
84
+ listener.update # type: ignore[attr‑defined]
85
+ if isinstance(listener, Observer) # type: ignore[misc]
86
+ else listener
87
+ )
88
+
89
+ with self._lock:
90
+ if event is None:
91
+ self._any_observers.discard(callback)
92
+ for s in self._observers.values():
93
+ s.discard(callback)
94
+ else:
95
+ self._observers.get(event, weakref.WeakSet()).discard(callback)
96
+ def __getstate__(self):
97
+ state = self.__dict__.copy()
98
+ state.pop('_lock', None) # RLock cannot be pickled
99
+ state.pop('_observers', None) # WeakSet cannot be pickled
100
+ state.pop('_any_observers', None)
101
+ return state
102
+ def __setstate__(self, state):
103
+ self.__dict__.update(state)
104
+ self._lock = threading.RLock()
105
+ self._observers = {}
106
+ self._any_observers = weakref.WeakSet()
107
+ self._frozen = 0
108
+ # ‑‑‑ notification api --------------------------------------------------
109
+ def notify(self: T, event: str, *args: Any, **kwargs: Any) -> None:
110
+ """Notify observers that *event* happened."""
111
+
112
+ with self._lock:
113
+ if self._frozen:
114
+ # defer until freeze_notifications() exits
115
+ self._pending.append((event, args, kwargs))
116
+ return
117
+
118
+ observers = list(self._any_observers)
119
+ observers.extend(self._observers.get(event, ()))
120
+
121
+ # Call outside lock — prevent deadlocks if observers trigger other
122
+ # notifications.
123
+ for cb in observers:
124
+ try:
125
+ cb(self, event, *args, **kwargs)
126
+ except Exception: # pragma: no cover
127
+ # Optionally log; never allow an observer error to break flow.
128
+ import logging
129
+
130
+ logging.getLogger(__name__).exception(
131
+ "Unhandled error in observer %s for event %s", cb, event
132
+ )
133
+
134
+ # ‑‑‑ batching ----------------------------------------------------------
135
+ @contextmanager
136
+ def freeze_notifications(self):
137
+ """Context manager that batches notifications until exit."""
138
+
139
+ with self._lock:
140
+ self._frozen += 1
141
+ try:
142
+ yield self
143
+ finally:
144
+ with self._lock:
145
+ self._frozen -= 1
146
+ if self._frozen == 0 and self._pending:
147
+ pending = self._pending[:]
148
+ self._pending.clear()
149
+ for event, args, kw in pending: # type: ignore[has‑type]
150
+ self.notify(event, *args, **kw)
LoopStructural/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "1.6.16"
1
+ __version__ = "1.6.18"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: LoopStructural
3
- Version: 1.6.16
3
+ Version: 1.6.18
4
4
  Summary: 3D geological modelling
5
5
  Author-email: Lachlan Grose <lachlan.grose@monash.edu>
6
6
  License: MIT
@@ -1,5 +1,5 @@
1
- LoopStructural/__init__.py,sha256=3yLT09qS3rOrQtyxsECOTh98B3sKv2IrE_DIfTSu930,2022
2
- LoopStructural/version.py,sha256=DGM49nQ783oxeemzhU9PZifWC3GA5fiS9MnhU4zNfso,23
1
+ LoopStructural/__init__.py,sha256=ZS5J2TI2OuhRE0o3EXmPbbjrvh1-Vy9RFwL7Hc1YBow,2079
2
+ LoopStructural/version.py,sha256=4Zzb1p1N54zNXVm2h5jVUlkFjge9TszzpDGANK9ZNPM,23
3
3
  LoopStructural/datasets/__init__.py,sha256=ylb7fzJU_DyQ73LlwQos7VamqkDSGITbbnoKg7KAOmE,677
4
4
  LoopStructural/datasets/_base.py,sha256=FB_D5ybBYHoaNbycdkpZcRffzjrrL1xp9X0k-pyob9Y,7618
5
5
  LoopStructural/datasets/_example_models.py,sha256=Zg33IeUyh4C-lC0DRMLqCDP2IrX8L-gNV1WxJwBGjzM,113
@@ -45,7 +45,7 @@ LoopStructural/interpolators/_constant_norm.py,sha256=gGaDGDoEzfnL4b6386YwInCxIA
45
45
  LoopStructural/interpolators/_discrete_fold_interpolator.py,sha256=eDe0R1lcQ0AuMcv7zlpu5c-soCv7AybIqQAuN2vFE3M,6542
46
46
  LoopStructural/interpolators/_discrete_interpolator.py,sha256=bPGJ1CrvLmz3m86JkXAiw7WbfbGEeGXR5cklDX54PQU,26083
47
47
  LoopStructural/interpolators/_finite_difference_interpolator.py,sha256=qc7zpqJka16I7yv-GigjQxF0hWRRHyWpHm8dHersy_8,18712
48
- LoopStructural/interpolators/_geological_interpolator.py,sha256=hdi8mFwHp4G3Tv3lXDvQey8QrRIHuk3KRbJcKtBh918,11460
48
+ LoopStructural/interpolators/_geological_interpolator.py,sha256=74tQUImZ9axkXUrWygl6vqspY3bsoHf_d3twVB30rho,11430
49
49
  LoopStructural/interpolators/_interpolator_builder.py,sha256=Z8bhmco5aSQX19A8It2SB_rG61wnlyshWfp3ivm8rU0,4586
50
50
  LoopStructural/interpolators/_interpolator_factory.py,sha256=fbjebXSe5IgTol1tnBlnsw9gD426v-TGkX3gquIg7LI,2782
51
51
  LoopStructural/interpolators/_interpolatortype.py,sha256=q8U9JGyFpO2FBA9XsMI5ojv3TV1LYqyvYHzLAbHcj9A,593
@@ -71,8 +71,9 @@ LoopStructural/interpolators/supports/_face_table.py,sha256=Hyj4Io63NkPRN8ab9uDH
71
71
  LoopStructural/interpolators/supports/_support_factory.py,sha256=XNAxnr-JS3KEhdsoZeJ-VaLTJwlvxgBuRMCqYrCDW18,1485
72
72
  LoopStructural/modelling/__init__.py,sha256=a-bq2gDhyUlcky5l9kl_IP3ExMdohkgYjQz2V8madQE,902
73
73
  LoopStructural/modelling/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
74
- LoopStructural/modelling/core/geological_model.py,sha256=asAyjVkO-uILX8QQgM8oqdkBQQfCjFDpuXyy2wn8lwc,65150
75
- LoopStructural/modelling/core/stratigraphic_column.py,sha256=ZHquRb6S97KwB8K8pqEGRSDah0-FjU2uOqdor8NMuY4,16828
74
+ LoopStructural/modelling/core/fault_topology.py,sha256=bChp5dnfc-4GJRENWxB14mEW_13uBMh5ZYRKbLdjweE,11195
75
+ LoopStructural/modelling/core/geological_model.py,sha256=f6bkpXdKxr72OozUb1vdoosLcjzMIht8VNgNlONluVA,65570
76
+ LoopStructural/modelling/core/stratigraphic_column.py,sha256=fmT51wInY3kei7Ya01cd5P5hcnhY5TkLBLJJwkUd_lE,20807
76
77
  LoopStructural/modelling/features/__init__.py,sha256=Vf-qd5EDBtJ1DpuXXyCcw2-wf6LWPRW5wzxDEO3vOc8,939
77
78
  LoopStructural/modelling/features/_analytical_feature.py,sha256=U_g86LgQhYY2359rdsDqpvziYwqrWkc5EdvhJARiUWo,3597
78
79
  LoopStructural/modelling/features/_base_geological_feature.py,sha256=kGyrbb8nNzfi-M8WSrVMEQYKtxThdcBxaji5HKXtAqw,13483
@@ -97,7 +98,7 @@ LoopStructural/modelling/features/fold/__init__.py,sha256=pOv20yQvshZozvmO_YFw2E
97
98
  LoopStructural/modelling/features/fold/_fold.py,sha256=bPnnLUSiF4uoMRg8aHoOSTPRgaM0JyLoRQPu5_A-J3w,5448
98
99
  LoopStructural/modelling/features/fold/_fold_rotation_angle_feature.py,sha256=CXLbFRQ3CrTMAcHmfdbKcmSvvLs9_6TLe0Wqi1pK2tg,892
99
100
  LoopStructural/modelling/features/fold/_foldframe.py,sha256=Rgf5aofN0OVDTZ2pzqLzAGlJUO2rnNm3aFvLSnH77yo,7669
100
- LoopStructural/modelling/features/fold/_svariogram.py,sha256=uzGaKZ5HGh8xZcsGGg68GUKVjkd5udLy7-4lh0NQc2Y,7765
101
+ LoopStructural/modelling/features/fold/_svariogram.py,sha256=uLeBWZahEmystf8mpPJH_zKxp7hPyPJh0H5ROZO0pZs,7933
101
102
  LoopStructural/modelling/features/fold/fold_function/__init__.py,sha256=VqMjabsBd5GnPnDMXeKwXqtd0te2iXnvHxpf6jCC9YU,830
102
103
  LoopStructural/modelling/features/fold/fold_function/_base_fold_rotation_angle.py,sha256=5Bu_5xjyu4KL7thZ4fsh938Ep3Oyh5TL7_rfz_13Qng,8047
103
104
  LoopStructural/modelling/features/fold/fold_function/_fourier_series_fold_rotation_angle.py,sha256=Cjb6Pt6cdRoH3WGqFyJ2rHxnMe6SKvzRayA6hTuwZA8,4069
@@ -115,8 +116,8 @@ LoopStructural/modelling/intrusions/intrusion_builder.py,sha256=PtNLDreUZTGodMwt
115
116
  LoopStructural/modelling/intrusions/intrusion_feature.py,sha256=ESjtikHFJQzUnowbYiY7UZ_kYdV2QHobQoRJ2far9Vc,15489
116
117
  LoopStructural/modelling/intrusions/intrusion_frame_builder.py,sha256=YEJv2GURAL8bW6J1KscM69ZVx9yoKVQKF_gXbjB513I,40150
117
118
  LoopStructural/modelling/intrusions/intrusion_support_functions.py,sha256=wodakheMD62WJyoKnyX8UO-C1pje0I-5kHQEoDqShzo,13951
118
- LoopStructural/utils/__init__.py,sha256=t-vJQ0cF2DrjSRtAfuPEL4hc73XJyQno7PucBnd-fu8,950
119
- LoopStructural/utils/_surface.py,sha256=Eg7x1GGfELl7bPe21_wU96Dn4JWJNReEFxwq-aIV4A4,6165
119
+ LoopStructural/utils/__init__.py,sha256=n-3PGnV-KEy4LOJkHZ6nlnoDyKDlfG6rJhXM0OgFYm0,1004
120
+ LoopStructural/utils/_surface.py,sha256=M0ZYujrqu-pWz-Y8iAhZsgoDxvI5YZG4GyhtBHdbCfk,6389
120
121
  LoopStructural/utils/_transformation.py,sha256=peuLPH3BJ5DxnPbOuNKcqK4eXhAXdbT540L1OIsO3v0,5404
121
122
  LoopStructural/utils/colours.py,sha256=-KRf1MXKx4L8TXnwyiunmKAX4tfy0qG68fRadyfn_bM,1163
122
123
  LoopStructural/utils/config.py,sha256=ITGOtZTo2_QBwXkG_0AFANfE90J9siCXLzxypVmg9QA,414
@@ -128,12 +129,13 @@ LoopStructural/utils/json_encoder.py,sha256=5YNouf1TlhjEqOYgthd07MRXc0JLgxern-ny
128
129
  LoopStructural/utils/linalg.py,sha256=tBXyu6NXcG2AcPuzUMnkVI4ncZWtE_MPHGj2PLXRwfY,123
129
130
  LoopStructural/utils/logging.py,sha256=dIUWEsS2lT4G1dsf4ZYXknTR7eQkrgvGA4b_E0vMIRU,2402
130
131
  LoopStructural/utils/maths.py,sha256=KaLj9RHsxdaSkEHm4t0JEzykhiuETAV14KpjL6lknWY,10374
132
+ LoopStructural/utils/observer.py,sha256=etHF2zhDWbrKAaIKAktdVLosD696o_B0P9rH3G5IzV0,5305
131
133
  LoopStructural/utils/regions.py,sha256=SjCC40GI7_n03G4mlcmvyrBgJFbxnvB3leBzXWco37o,3891
132
134
  LoopStructural/utils/typing.py,sha256=29uVSTZdzXXH-jdlaYyBWZ1gQ2-nlZ2-XoVgG_PXNFY,157
133
135
  LoopStructural/utils/utils.py,sha256=2Z4zVE6G752-SPmM29zebk82bROJxEwi_YiiJjcVED4,2438
134
136
  LoopStructural/visualisation/__init__.py,sha256=5BDgKor8-ae6DrS7IZybJ3Wq_pTnCchxuY4EgzA7v1M,318
135
- loopstructural-1.6.16.dist-info/licenses/LICENSE,sha256=ZqGeNFOgmYevj7Ld7Q-kR4lAxWXuBRUdUmPC6XM_py8,1071
136
- loopstructural-1.6.16.dist-info/METADATA,sha256=uvSdqdLD2MRCeE972hyitE5L5UfIne1o5uVRXzIPIPU,6453
137
- loopstructural-1.6.16.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
138
- loopstructural-1.6.16.dist-info/top_level.txt,sha256=QtQErKzYHfg6ddxTQ1NyaTxXBVM6qAqrM_vxEPyXZLg,15
139
- loopstructural-1.6.16.dist-info/RECORD,,
137
+ loopstructural-1.6.18.dist-info/licenses/LICENSE,sha256=ZqGeNFOgmYevj7Ld7Q-kR4lAxWXuBRUdUmPC6XM_py8,1071
138
+ loopstructural-1.6.18.dist-info/METADATA,sha256=Ps1KAvAFlPgR11P0nbTfYrMlcPS6bX3OewD1UKRXEnU,6453
139
+ loopstructural-1.6.18.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
140
+ loopstructural-1.6.18.dist-info/top_level.txt,sha256=QtQErKzYHfg6ddxTQ1NyaTxXBVM6qAqrM_vxEPyXZLg,15
141
+ loopstructural-1.6.18.dist-info/RECORD,,