StructureCloud 0.0.1__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.
@@ -0,0 +1 @@
1
+ from .retrieval import StructureCloudDataset
@@ -0,0 +1,373 @@
1
+ import os
2
+ import sys
3
+ import csv
4
+ import json
5
+ import pickle
6
+ import multiprocessing as mp
7
+
8
+ import matplotlib.pyplot as plt
9
+
10
+ import numpy as np
11
+ import torch
12
+ from rdkit import Chem
13
+ from tqdm import tqdm
14
+
15
+ import gzip
16
+ import shutil
17
+
18
+ '''
19
+ USEFUL FUNCTIONS FOR DATASET STATS PLOT AND SAVING
20
+
21
+ Instructions:
22
+ 1. create some iterable that load your source data in the following format:
23
+
24
+ node_pos, node_class, unitcell, labels = dataset[idx]
25
+ note: labels is a dictionary of one or two dictionaries containing labels
26
+ labels[object] = object wise labels
27
+ labels[node] = node wise labels
28
+
29
+ 2. pass this iterable to the dataset_histogram function and save the plot
30
+
31
+ fig, stat_dict = dataset_histogram(dataset, max_sample_size=-1, full_data_range=True, random_order=True)
32
+ fig.savefig('dataset_histogram.png')
33
+
34
+ if your dataset is too large, you can set max_sample_size to a smaller number
35
+
36
+ 3. save your dataset into a set of chunks in either a json or pickle file format
37
+
38
+ save_to_chunks( dataset,
39
+ output_directory, # the directory to save data chunks to, will be made if it does not exist
40
+ chunk_size=-1, # this means the entire dataset will be saved in one file
41
+ file_type='pkl', # 'pkl' or 'json'
42
+ jobs=1, # number of parallel jobs to use for saving. Default is 1 (no parallelism).
43
+ progress_bar=True # show a progress tqdm bar for saving in a notebook
44
+ )
45
+
46
+
47
+ '''
48
+
49
+
50
+ def cannonicalize_smiles(smiles):
51
+ """
52
+ Convert a SMILES string to its canonical form.
53
+ """
54
+ mol = Chem.MolFromSmiles(smiles)
55
+ if mol is None:
56
+ # raise ValueError(f'Invalid SMILES string: "{smiles}" ')
57
+ print(f'Invalid SMILES string!! : "{smiles}" ')
58
+ return None
59
+ return Chem.MolToSmiles(mol, canonical=True)
60
+
61
+ def get_bounding_box(points):
62
+ """
63
+ Calculate the bounding box of a set of points.
64
+ """
65
+ if isinstance(points, torch.Tensor):
66
+ points = points.detach().cpu().numpy()
67
+
68
+ min_x = np.min(points[:, 0])
69
+ max_x = np.max(points[:, 0])
70
+ min_y = np.min(points[:, 1])
71
+ max_y = np.max(points[:, 1])
72
+ min_z = np.min(points[:, 2])
73
+ max_z = np.max(points[:, 2])
74
+
75
+ length_x = max_x - min_x
76
+ length_y = max_y - min_y
77
+ length_z = max_z - min_z
78
+
79
+ #convert into a unit cell, a unit cell is a 3x3 matrix
80
+ unit_cell = np.array([[length_x, 0, 0],
81
+ [0, length_y, 0],
82
+ [0, 0, length_z]])
83
+
84
+ return torch.tensor(unit_cell)
85
+
86
+
87
+ from scipy.spatial.distance import pdist
88
+ def longest_unit_cell_diagonal(cell: np.ndarray) -> float:
89
+ indices = np.array([[i, j, k] for i in [0, 1] for j in [0, 1] for k in [0, 1]])
90
+ corners = indices @ cell.T
91
+ return np.max(pdist(corners))
92
+
93
+
94
+
95
+ def dataset_histogram(dataset, max_sample_size : int =100000, full_data_range: bool =True, random_order : bool = True, seed=12345) -> plt.Figure:
96
+ '''
97
+ input needs to be cannonical dataset with output format pos, node_class, unit_cell, labels
98
+ dataset (iterable) : object providing samples
99
+ max_sample_size (int) : maximum number of samples to use for the histograms.
100
+ sampling the entire dataset may be slow, or may not fit in memory.
101
+ if max_sample_size < 1, this will sample the entire dataset.
102
+ full_data_range (bool) : if True, this will iterate over the entire dataset to compute the range (min,max) of
103
+ number of points (N), length of the unit cell (min, max), and range of elements (n_total)
104
+ random_order (bool) : if True, this will shuffle the dataset before sampling.
105
+ seed (int) : random seed for reproducibility
106
+
107
+ Example usage:
108
+ fig = dataset_histogram(dataset, max_sample_size=-1, full_data_range=True, random_order=True)
109
+ fig.savefig('dataset_histogram.png')
110
+ '''
111
+
112
+ if max_sample_size < 1:
113
+ max_sample_size = len(dataset)
114
+
115
+ max_N = -1
116
+ min_N = np.inf
117
+
118
+ max_ldiag = -1
119
+ min_ldiag = np.inf
120
+ max_lxyz = -1
121
+ min_lxyz = np.inf
122
+ unique_node_id_count = -1 # number of uniques elements in dataset
123
+
124
+ N_sample = []
125
+ lxyz_sample = []
126
+ ldiag_sample = []
127
+ id_samples = []
128
+ id_count = {} # store a count of unique elements/node classes in the dataset
129
+ n_count = {}
130
+
131
+ order = np.arange(len(dataset))
132
+ if random_order:
133
+ rand_gen = np.random.default_rng(seed)
134
+ rand_gen.shuffle(order)
135
+
136
+ for i, idx in enumerate(tqdm(order)):
137
+ #assume outputs are in tensor or numpy format
138
+ pos, node_id, unit_cell, labels = dataset[idx]
139
+
140
+ N = pos.shape[0]
141
+ if isinstance(N, torch.Tensor):
142
+ N = N.item()
143
+
144
+ node_id = node_id.flatten()
145
+ if isinstance(node_id, torch.Tensor):
146
+ node_id = node_id.tolist()
147
+
148
+ if N > max_N:
149
+ max_N = N
150
+ if N < min_N:
151
+ min_N = N
152
+
153
+ #check if N is in n_count
154
+ if N in n_count:
155
+ n_count[N] += 1
156
+ else:
157
+ n_count[N] = 1
158
+
159
+ for id in node_id:
160
+ assert isinstance(id, int), f"node_id must be an integer, got {type(id)}"
161
+ if id in id_count:
162
+ id_count[id] += 1
163
+ else:
164
+ id_count[id] = 1
165
+ unique_node_id_count += 1
166
+
167
+ #orthogonalize the unit cell to get the lengths
168
+ lxyz = np.linalg.norm(unit_cell, axis=1).reshape(-1) #NOTE: this assumes lattice vectores are stacked on dim 0
169
+ lxyz_max_i = np.max(lxyz)
170
+ lxyz_min_i = np.min(lxyz)
171
+
172
+ if lxyz_max_i > max_lxyz:
173
+ max_lxyz = lxyz_max_i
174
+ if lxyz_min_i < min_lxyz:
175
+ min_lxyz = lxyz_min_i
176
+
177
+ ldiag_i = longest_unit_cell_diagonal(unit_cell.numpy())
178
+ if ldiag_i > max_ldiag:
179
+ max_ldiag = ldiag_i
180
+ if ldiag_i < min_ldiag:
181
+ min_ldiag = ldiag_i
182
+
183
+ if i <= max_sample_size-1:
184
+ N_sample.append(N)
185
+ id_samples.extend(node_id)
186
+ lxyz_sample.extend(lxyz.tolist())
187
+ ldiag_sample.append(ldiag_i)
188
+ elif not full_data_range:
189
+ break
190
+
191
+ range_dict = {}
192
+ range_dict['N'] = (min_N, max_N)
193
+ range_dict['Lxyz'] = (min_lxyz, max_lxyz)
194
+ range_dict['Ldiag'] = (min_ldiag, max_ldiag)
195
+ range_dict['unique_node_ids'] = id_count.keys()
196
+ range_dict['unique_node_ids_count'] = len(id_count.keys())
197
+
198
+ N_bins = np.arange(min_N, max_N+1)
199
+ L_bins = np.linspace(min_lxyz, max_ldiag, 100)
200
+
201
+ #plot histograms
202
+ fig_scale = 2
203
+ fig, ax = plt.subplots(3, 1, figsize=(5*fig_scale, 5*fig_scale))
204
+ ax[0].hist(N_sample, bins=N_bins, density=True, alpha=1)
205
+ ax[0].set_xlabel('Number of atoms (N)')
206
+ ax[0].set_ylabel('Fraction')
207
+ ax[0].set_title('Number of nodes in dataset: min={}, max={}'.format(min_N, max_N))
208
+
209
+
210
+ ax[1].hist(lxyz_sample, bins=L_bins, density=True, alpha=0.6, label='lxyz')
211
+ ax[1].hist(ldiag_sample, bins=L_bins, density=True, alpha=0.6, label='ldiag')
212
+ ax[1].set_xlabel('Length of unit cell side (lxyz) and max diagonal (ldiag)')
213
+ ax[1].set_ylabel('Fraction')
214
+ ax[1].legend()
215
+ ax[1].set_title(f'Unitcell lengths in dataset: min={min_lxyz:0.3f}, max={max_ldiag:0.2f}')
216
+
217
+ #use id_count dictionary to plot a bar graph of the number of unique elements
218
+ id_max_count = max(id_count.values())
219
+ id_relative_count = [v/id_max_count for v in id_count.values()]
220
+
221
+ #convert id_count.keys() to a list
222
+ string_ids = [str(id) for id in np.sort(list(id_count.keys()))] #.to_list().sort
223
+ ax[2].bar(string_ids, id_relative_count)
224
+ ax[2].set_xlabel('Unique node ids (elements)')
225
+ ax[2].set_ylabel('Relative Count')
226
+
227
+ fig.suptitle('Histogram sample size: {}'.format(len(N_sample)))
228
+ fig.tight_layout()
229
+ fig.subplots_adjust(top=0.9)
230
+
231
+ return fig, range_dict
232
+
233
+
234
+
235
+ #### functions for saving to files ###
236
+
237
+ def format_values(value):
238
+ """
239
+ Convert a value to a format suitable for JSON serialization.
240
+ """
241
+ if isinstance(value, torch.Tensor):
242
+ return value.detach().cpu().tolist()
243
+ elif isinstance(value, np.ndarray):
244
+ return value.tolist()
245
+ elif isinstance(value, list):
246
+ return [format_values(v) for v in value]
247
+ elif isinstance(value, dict):
248
+ return {k: format_values(v) for k, v in value.items()}
249
+ else:
250
+ return value
251
+
252
+ def write_chunk(chunk, chunk_name : str, output_dir : str, file_type : str, compress : bool):
253
+ '''
254
+ chunk : list of dictionaries, each dictionary is a sample
255
+ '''
256
+ file_ext = {'json': 'jsonl', # default to jsonl
257
+ 'jsonl': 'jsonl', # default to jsonl
258
+ 'pkl': 'pkl',
259
+ 'pickle' : 'pkl'}[file_type]
260
+
261
+ chunk_path = os.path.join(output_dir, f'{chunk_name}.{file_ext}')
262
+ open_cmd = gzip.open if compress else open
263
+ if compress:
264
+ # add .gz to the filename
265
+ chunk_path += '.gz'
266
+
267
+ if file_ext == 'pkl':
268
+ with open_cmd(chunk_path, 'wb') as f:
269
+ pickle.dump(chunk, f)
270
+ elif file_ext == 'jsonl':
271
+ with open_cmd(chunk_path, 'wt', encoding='utf-8') as f:
272
+ json_lines = [json.dumps(item) for item in chunk]
273
+ f.write('\n'.join(json_lines) + '\n')
274
+ else:
275
+ raise ValueError(f"Unsupported file type: {file_type}. Supported types are: {list(file_ext.keys())}")
276
+
277
+ def process_dataset_chunk(dataset, data_indices, chunk_name, output_dir, file_type, compress, progress_bar=False):
278
+ if progress_bar:
279
+ data_indices = tqdm(data_indices, desc=f"Formating {chunk_name}")
280
+
281
+ data_list = []
282
+ for idx in data_indices:
283
+ position, node_class, unitcell, labels = dataset[idx]
284
+
285
+ # Format each sample
286
+ sample = {
287
+ 'position': format_values(position),
288
+ 'node_class': format_values(node_class),
289
+ 'unitcell': format_values(unitcell),
290
+ 'labels': format_values(labels),
291
+ }
292
+ data_list.append(sample)
293
+
294
+ if progress_bar:
295
+ print(f"Writing chunk {chunk_name} with {len(data_list)} samples")
296
+ write_chunk(data_list, chunk_name, output_dir, file_type, compress)
297
+ return chunk_name
298
+
299
+
300
+ def save_to_chunks(dataset, output_dir, chunk_size=-1, file_type='json', jobs=1,
301
+ prefix='chunk', progress_bar=True, compress=True):
302
+ """
303
+ Save the entire dataset to a single JSON file or series of JSON files for a given chunksize.
304
+
305
+
306
+ Args:
307
+ dataset (iterable): Object providing samples in the standard format.
308
+ output_path (str): Path to save the JSON file.
309
+ chunk_size (int): Number of samples to save in each chunk.
310
+ If -1, the entire dataset will be saved in one file.
311
+ note: chunking will try and maintain fiel size regularity
312
+ actual chunking may not be the same as chunk_size
313
+ file_type (str): Type of file to save. 'json' or 'pkl' for pickle.
314
+ jobs (int): Number of parallel jobs to use for saving. Default is 1 (no parallelism).
315
+ prefix (str): Prefix for the chunk files. Default is 'chunk_i'.
316
+ compress (bool): If True, compress the JSON file using gzip. Default is True.
317
+
318
+ Dataset sample output must be:
319
+ position, node_class, unitcell, labels = dataset[idx]
320
+ """
321
+
322
+ # Ensure output directory exists
323
+ if not os.path.exists(output_dir):
324
+ print(f"Output directory {output_dir} does not exist. Creating directory.")
325
+ os.makedirs(output_dir, exist_ok=False)
326
+
327
+ if chunk_size is None or chunk_size < 0:
328
+ #save the entire dataset in one file
329
+ chunk_size = len(dataset)
330
+
331
+ #determine the number of chunks
332
+ num_chunks = len(dataset) // chunk_size + (1 if len(dataset) % chunk_size > 0 else 0)
333
+ print(f"Saving dataset in {num_chunks} chunks of size {chunk_size} with {jobs} jobs...")
334
+
335
+ jobs = max(min(jobs, num_chunks), 1) # limit the number of jobs to the number of chunks
336
+
337
+ all_data_indices = np.arange(len(dataset))
338
+ chunk_indices = np.array_split(all_data_indices, num_chunks)
339
+ print(f"Chunk sizes: {[len(c) for c in chunk_indices]}")
340
+ job_args = []
341
+ for i in range(num_chunks):
342
+ chunk_name = f'{prefix}_{i+1}'
343
+ chunk_indices_i = chunk_indices[i]
344
+ kwargs = {'dataset': dataset,
345
+ 'data_indices': chunk_indices_i,
346
+ 'chunk_name': chunk_name,
347
+ 'output_dir': output_dir,
348
+ 'file_type': file_type,
349
+ 'compress': compress,}
350
+ job_args.append(kwargs)
351
+
352
+ if jobs <= 1:
353
+ for i, job_args in enumerate(job_args):
354
+ process_dataset_chunk(**job_args, progress_bar=progress_bar)
355
+ else:
356
+ # Use multiprocessing to save chunks in parallel
357
+ def callback_fn(out):
358
+ print(f"{out} saved successfully")
359
+ def err_callback_fn(out):
360
+ print(f"Error in process: {out}")
361
+
362
+ with mp.Pool(processes=jobs) as pool:
363
+ job_outs = []
364
+ for i, kwargs in enumerate(job_args):
365
+ res = pool.apply_async(process_dataset_chunk, kwds=kwargs, callback=callback_fn, error_callback=err_callback_fn)
366
+ job_outs.append(res)
367
+
368
+ pool.close()
369
+ pool.join()
370
+ print("All jobs complete!")
371
+
372
+ print(f"Data saved to {output_dir}")
373
+
@@ -0,0 +1,53 @@
1
+
2
+ import numpy as np
3
+ from datasets import load_dataset
4
+ from datasets.dataset_dict import DatasetDict
5
+
6
+ class StructureCloudDataset():
7
+ '''
8
+ A very simple class for loading StructureCloud datasets
9
+ '''
10
+ def __init__(self, dataset_name, num_fmt : callable = np.array, label_fmt : callable = None, **huggingface_kwargs):
11
+ '''
12
+ Args:
13
+ dataset_name (str): name of the dataset to load
14
+ num_fmt (type): the numerical format to use for the data.
15
+ default is numpy array, but can be changed to torch tensor or other formats
16
+ label_fmt (type): a function that takes in the labels and returns a desired format of the labels.
17
+ default is None, which means labels are returned as loaded
18
+ huggingface_kwargs (dict): additional arguments to pass to load_dataset
19
+
20
+ Default numerical format is numpy array
21
+ '''
22
+
23
+ if label_fmt is None:
24
+ label_fmt = lambda x: x # no formatting
25
+
26
+ self.label_fmt = label_fmt
27
+ self.num_fmt = num_fmt
28
+ self.dataset_name = dataset_name
29
+
30
+ ds = load_dataset(
31
+ f'StructureCloud/{dataset_name}',
32
+ **huggingface_kwargs
33
+ )
34
+
35
+ if isinstance(ds, DatasetDict):
36
+ splits = list(ds.keys())
37
+ if len(splits) > 1:
38
+ print(f'Multiple splits found, but none specified: {splits} \nDefaulting to {splits[0]}')
39
+ ds = ds[splits[0]]
40
+
41
+ self.data = ds
42
+
43
+ def __len__(self):
44
+ return len(self.data)
45
+
46
+ def __getitem__(self, idx):
47
+ #get the data at select index and reformat it
48
+ data = self.data[idx]
49
+ pos = self.num_fmt(data['position'])
50
+ node_class = self.num_fmt(data['node_class'])
51
+ unit_cell = self.num_fmt(data['unitcell'])
52
+ labels = self.label_fmt(data['labels'])
53
+ return pos, node_class, unit_cell, labels
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: StructureCloud
3
+ Version: 0.0.1
4
+ Summary: Standard formating and easy access to 3D structural datasets for machine learning. currently under development...
5
+ Author-email: Ty Perez <ty.jperez@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/TyJPerez/StructureCloud
8
+ Project-URL: Issues, https://github.com/TyJPerez/StructureCloud/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Dynamic: license-file
15
+
16
+ # StructureCloud
17
+ A collection of 3D Point cloud datasets commonly used for training generative modeling and molecular discovery.
18
+
19
+ ## Install
20
+
21
+ install hugging face dataset library
22
+ ```bash
23
+ pip install datasets
24
+ ```
25
+
26
+ install StructureCloud (TODO: not yet on PyPi)
27
+ ```bash
28
+ $ pip install StructureCloud
29
+ ```
30
+
31
+
32
+ ## StructureCloud Datasets
33
+ StructureCloud is aimed at simplifying pointcloud dataset retrieval and manipulation with a simple unifying output format. The datasets in StructureCloud are chosen speifically for training models that adress problems related to 3D structures.
34
+
35
+
36
+ ## Usage
37
+ ```python
38
+ import numpy as np
39
+ import torch
40
+ from StructureCloud.Datasets import StructureCloudDataset as scd
41
+
42
+ # load numerical values as numpy objects
43
+ dataset_default = scd('dataset_name', split='train')
44
+
45
+ # load numerical values as torch tensors
46
+ dataset_torch = scd('dataset_name', split='train', num_fmt=torch.tensor)
47
+
48
+ #dataset output format is as follows
49
+ positions, features, unitcell, lables = dataset[index]
50
+
51
+ # output shapes:
52
+ # positions [N, 3] - positions in 3d space
53
+ # features [N, 1] or [N, d] - node feature/identities (ie element#, class, etc)
54
+ # unitcell/bounding box [3,3]
55
+ # labels - a dictionary
56
+ # lables['node'] = { dict of nodewise lables : [N,d] }
57
+ # labels['object] = { dict of whole object labels : [d] }
58
+
59
+
60
+ #### selecting and formating data labels ###
61
+ ## by default, the labels dictonary returns all additional information associated with the dataset.
62
+ ## but a specific label can be selected by defining a formating function in 'label_fmt'
63
+
64
+ get_object_target = lambda x : torch.tensor(x['object']['targets'])
65
+ get_object_smiles = lambda x : x['object']['SMILES']
66
+
67
+ qm9_regression = StructureCloudDataset('QM9', label_fmt = get_object_target)
68
+ qm9_smiles = StructureCloudDataset('QM9', label_fmt = get_object_smiles )
69
+
70
+ reg_target = qm9_regression[1000][3]
71
+ smiles = qm9_smiles[1000][3]
72
+ print(smiles)
73
+ print(reg_target)
74
+
75
+ ```
76
+
77
+ ### Available Datasets
78
+ **Small Organic Molecules**
79
+ - QM9
80
+ - PCQM4Mv2
81
+ - GEOM
82
+
83
+ **Proteins and biomolecules**
84
+ - AlphaFold Homo Sapiens (proteins)
85
+ - (LP)PDBbind2020
86
+
87
+ **Materials**
88
+ - MP-20
89
+ - Perov-5
90
+ - Carbon-24
91
+ - MPTS-52
92
+ - PCOD2
93
+
94
+ **3D objects**
95
+ - coming soon...
96
+
97
+ **Gaussian Splats**
98
+ - coming soon..,
99
+ -
100
+
101
+
@@ -0,0 +1,8 @@
1
+ StructureCloud/Datasets/__init__.py,sha256=-GaeVr8Yl-LlEflhB2e3ruYA6rLYjmgsVhbDXIwZgoQ,44
2
+ StructureCloud/Datasets/preprocess.py,sha256=BHFzk8PmlFi90rAu7CJDwwKEXBUQIVlN7fKW_lis-a0,13485
3
+ StructureCloud/Datasets/retrieval.py,sha256=A75FrkIszbg7dMCTn2MgGex2j3tmE9B1CUlDVsd4PnA,1941
4
+ structurecloud-0.0.1.dist-info/licenses/LICENSE,sha256=M0UsffaFmIbimAAw2XPN4kv2hqKKT6Mo-pHznzlRdXo,1068
5
+ structurecloud-0.0.1.dist-info/METADATA,sha256=XQ7XFp3egvs3ahEDlxTpi3s8XBoALUEy_E4wvYOHmJM,2821
6
+ structurecloud-0.0.1.dist-info/WHEEL,sha256=ck4Vq1_RXyvS4Jt6SI0Vz6fyVs4GWg7AINwpsaGEgPE,91
7
+ structurecloud-0.0.1.dist-info/top_level.txt,sha256=hFrxbO8UXOS4GgjgANtNaTxCdUSkHlhyXYM3liKWpTA,15
8
+ structurecloud-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Tynan Perez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ StructureCloud