digichem-core 6.0.3__py3-none-any.whl → 6.10.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.
digichem/file/cube.py CHANGED
@@ -3,11 +3,17 @@
3
3
  from pathlib import Path
4
4
  import subprocess
5
5
  import tempfile
6
- import shutil
6
+ import asyncio
7
7
  import os
8
8
 
9
- from digichem.exception.base import File_maker_exception
9
+ try:
10
+ from pyscf.tools import cubegen
11
+
12
+ except ModuleNotFoundError:
13
+ # No PySCF.
14
+ cubegen = None
10
15
 
16
+ from digichem.exception.base import File_maker_exception
11
17
  from digichem.file import File_converter
12
18
  import digichem.file.types as file_types
13
19
  import digichem.log
@@ -81,6 +87,7 @@ class Fchk_to_cube(File_converter):
81
87
  memory = None,
82
88
  cubegen_executable = "cubegen",
83
89
  sanitize = False,
90
+ multithreading = None,
84
91
  **kwargs):
85
92
  """
86
93
  Constructor for Fchk_to_cube objects.
@@ -104,12 +111,18 @@ class Fchk_to_cube(File_converter):
104
111
  memory = memory if memory is not None else "3 GB"
105
112
  self.memory = Memory(memory)
106
113
  # Default to number of CPUs of the system.
107
- self.num_cpu = int(num_cpu) if num_cpu is not None else os.cpu_count()
114
+ # If we've not been asked to run multithreaded, use 1 CPU.
115
+ if multithreading is None or multithreading == "pool":
116
+ self.num_cpu = 1
117
+
118
+ else:
119
+ self.num_cpu = int(num_cpu) if num_cpu is not None else os.cpu_count()
120
+
108
121
  self.cubegen_executable = expand_path(cubegen_executable)
109
122
  self.sanitize = sanitize
110
123
 
111
124
  @classmethod
112
- def from_options(self, output, *, fchk_file = None, cubegen_type = "MO", orbital = "HOMO", options, **kwargs):
125
+ def from_options(self, output, *, fchk_file = None, cubegen_type = "MO", orbital = "HOMO", num_cpu = None, options, **kwargs):
113
126
  """
114
127
  Constructor that takes a dictionary of config like options.
115
128
  """
@@ -122,34 +135,115 @@ class Fchk_to_cube(File_converter):
122
135
  dont_modify = not options['render']['enable_rendering'],
123
136
  cubegen_executable = options['external']['cubegen'],
124
137
  sanitize = options['render']['safe_cubes'],
138
+ num_cpu = num_cpu,
139
+ multithreading = options['external']['cubegen_parallel'],
125
140
  **kwargs
126
141
  )
142
+
143
+ def get_parallel(self, name = 'file', cpus = 1):
144
+ """
145
+ Generate the file represented by this object in a parallel context.
146
+
147
+ get_parallel() will be called by a higher level function as an argument to ThreadPoolExecutor.map() or similar,
148
+ to generate many files simultaneously. This is useful for slow operations (such as cube generation) that are
149
+ difficult to parallelise individually, but easy to parallelise across multiple files.
150
+
151
+ :param name: The file to generate.
152
+ :param cpus: The number of CPUs this operation should use, nearly always 1.
153
+ """
154
+ # Temporarily set CPUs to 1 (in-case it got changed somewhere else.)
155
+ old_cpus = self.num_cpu
156
+ self.num_cpu = cpus
157
+ try:
158
+ self.get_file(name = name)
127
159
 
128
- def make_files(self):
160
+ finally:
161
+ self.num_cpu = old_cpus
162
+
163
+ @property
164
+ def signature(self):
129
165
  """
130
- Make the files referenced by this object.
166
+ Get the call signature to run cubegen (as can be passed to Popen etc)
131
167
  """
132
- # The signature we'll use to call cubegen.
133
- signature = [
168
+ return [
134
169
  "{}".format(self.cubegen_executable),
135
- #"{}".format(self.num_cpu),
136
- "0", # Disable CPUs for now, cubegen does not respond well to >1.
170
+ # Some versions of cubegen respect this num_threads arg, some don't.
171
+ "{}".format(self.num_cpu),
137
172
  "{}={}".format(self.cubegen_type, self.orbital),
138
173
  str(self.input_file),
139
174
  str(self.output),
140
175
  str(self.npts)
141
176
  ]
177
+
178
+ @property
179
+ def env(self):
180
+ """
181
+ Get the environmental variables require to run cubegen (as can be passed to Popen etc).
182
+ """
183
+ return dict(
184
+ os.environ,
185
+ GAUSS_MEMDEF = str(self.memory),
186
+ # Some versions of cubegen only respect this way of changing threads.
187
+ OMP_NUM_THREADS = str(self.num_cpu)
188
+ )
189
+
190
+ async def make_files_async(self):
191
+ """
192
+ Make the files referenced by this object.
193
+ """
194
+ try:
195
+ cubegen_proc = await asyncio.create_subprocess_exec(
196
+ # Unless subprocess.run(), create_subprocess_exec() takes and expanded list as first arg...
197
+ *self.signature,
198
+ # Capture both stdout and stderr.
199
+ # It appears that cubegen writes everything (including error messages) to stdout, but it does return meaningful exit codes.
200
+ stdout = subprocess.PIPE,
201
+ stderr = subprocess.STDOUT,
202
+ env = self.env
203
+ )
204
+ except FileNotFoundError:
205
+ raise File_maker_exception(self, "Could not locate cubegen executable '{}'".format(self.cubegen_executable))
206
+
207
+ stdout = ""
208
+ stderr = ""
209
+ # Wait for completion.
210
+ while cubegen_proc.returncode is None:
211
+ retval = await cubegen_proc.communicate()
212
+ stdout += retval[0].decode()
213
+ stderr += retval[1].decode()
214
+
215
+ # If something went wrong, dump output.
216
+ if cubegen_proc.returncode != 0:
217
+ # An error occured.
218
+ # Check if the input file exists, if not this is probably what went wrong.
219
+ message = "Cubegen did not exit successfully"
220
+ if not Path(str(self.input_file)).exists():
221
+ message += " (probably because the input file '{}' could not be found)".format(self.input_file)
222
+ message += ":\n{}".format(stdout)
223
+ raise File_maker_exception(self, message)
224
+ else:
225
+ # Everything appeared to go ok.
226
+ # Dump cubegen output if we're in debug.
227
+ digichem.log.get_logger().debug(stdout)
142
228
 
229
+ if self.sanitize:
230
+ sanitize_modern_cubes(self.output)
231
+
232
+
233
+ def make_files(self):
234
+ """
235
+ Make the files referenced by this object.
236
+ """
143
237
  try:
144
238
  cubegen_proc = subprocess.run(
145
- signature,
239
+ self.signature,
146
240
  # Capture both stdout and stderr.
147
241
  # It appears that cubegen writes everything (including error messages) to stdout, but it does return meaningful exit codes.
148
242
  stdout = subprocess.PIPE,
149
243
  stderr = subprocess.STDOUT,
150
244
  universal_newlines = True,
151
- env = dict(os.environ, GAUSS_MEMDEF = str(self.memory))
152
- )
245
+ env = self.env
246
+ )
153
247
  except FileNotFoundError:
154
248
  raise File_maker_exception(self, "Could not locate cubegen executable '{}'".format(self.cubegen_executable))
155
249
 
@@ -193,7 +287,7 @@ class Fchk_to_spin_cube(Fchk_to_cube):
193
287
  return super().__init__(*args, cubegen_type = "Spin", orbital = spin_density, **kwargs)
194
288
 
195
289
  @classmethod
196
- def from_options(self, output, *, fchk_file = None, spin_density = "SCF", options, **kwargs):
290
+ def from_options(self, output, *, fchk_file = None, spin_density = "SCF", num_cpu = None, options, **kwargs):
197
291
  """
198
292
  Constructor that takes a dictionary of config like options.
199
293
  """
@@ -205,6 +299,8 @@ class Fchk_to_spin_cube(Fchk_to_cube):
205
299
  dont_modify = not options['render']['enable_rendering'],
206
300
  cubegen_executable = options['external']['cubegen'],
207
301
  sanitize = options['render']['safe_cubes'],
302
+ num_cpu = num_cpu,
303
+ multithreading = options['external']['cubegen_parallel'],
208
304
  **kwargs
209
305
  )
210
306
 
@@ -231,7 +327,7 @@ class Fchk_to_density_cube(Fchk_to_cube):
231
327
  self.type = density_type
232
328
 
233
329
  @classmethod
234
- def from_options(self, output, *, fchk_file = None, density_type = "SCF", options, **kwargs):
330
+ def from_options(self, output, *, fchk_file = None, density_type = "SCF", num_cpu = None, options, **kwargs):
235
331
  """
236
332
  Constructor that takes a dictionary of config like options.
237
333
  """
@@ -243,6 +339,8 @@ class Fchk_to_density_cube(Fchk_to_cube):
243
339
  dont_modify = not options['render']['enable_rendering'],
244
340
  cubegen_executable = options['external']['cubegen'],
245
341
  sanitize = options['render']['safe_cubes'],
342
+ num_cpu = num_cpu,
343
+ multithreading = options['external']['cubegen_parallel'],
246
344
  **kwargs
247
345
  )
248
346
 
@@ -268,7 +366,7 @@ class Fchk_to_nto_cube(Fchk_to_cube):
268
366
  super().__init__(*args, cubegen_type = "MO", orbital = orbital, **kwargs)
269
367
 
270
368
  @classmethod
271
- def from_options(self, output, *, fchk_file = None, orbital = "HOMO", options, **kwargs):
369
+ def from_options(self, output, *, fchk_file = None, orbital = "HOMO", num_cpu = None, options, **kwargs):
272
370
  """
273
371
  Constructor that takes a dictionary of config like options.
274
372
  """
@@ -280,5 +378,76 @@ class Fchk_to_nto_cube(Fchk_to_cube):
280
378
  dont_modify = not options['render']['enable_rendering'],
281
379
  cubegen_executable = options['external']['cubegen'],
282
380
  sanitize = options['render']['safe_cubes'],
381
+ num_cpu = num_cpu,
382
+ multithreading = options['external']['cubegen_parallel'],
283
383
  **kwargs
284
384
  )
385
+
386
+ class PySCF_to_cube(File_maker):
387
+ """
388
+ Generate cubes from a completed PySCF calculation result object.
389
+ """
390
+
391
+ def __init__(
392
+ self,
393
+ *args,
394
+ mol,
395
+ target,
396
+ target_type = "density",
397
+ npts = 80,
398
+ cube_file = None,
399
+ sanitize = False,
400
+ **kwargs):
401
+ """
402
+ Constructor for Fchk_to_cube objects.
403
+
404
+ See Image_maker for a full signature.
405
+
406
+ :param output: The filename/path to the cube file (this path doesn't need to point to a real file yet; we will use this path to write to).
407
+ :param npts: The number of points per side of the cube.
408
+ :param cube_file: An optional file path to an existing cube file to use. If this is given (and points to an actual file), then a new cube will not be made and this file will be used instead.
409
+ :param sanitize: Whether to modify the cube file to make it compatible with older software.
410
+ """
411
+ super().__init__(*args, existing_file = cube_file, **kwargs)
412
+ self.npts = npts
413
+ self.sanitize = sanitize
414
+ self.target_type = target_type
415
+ self.target = target
416
+ self.mol = mol
417
+ # TODO: Add some intelligence to this...
418
+ self.type = "SCF"
419
+
420
+ @classmethod
421
+ def from_options(self, output, *, options, **kwargs):
422
+ """
423
+ Constructor that takes a dictionary of config like options.
424
+ """
425
+ return self(
426
+ output,
427
+ npts = options['render']['orbital']['cube_grid_size'].translate("points"),
428
+ dont_modify = not options['render']['enable_rendering'],
429
+ sanitize = options['render']['safe_cubes'],
430
+ **kwargs
431
+ )
432
+
433
+ def check_can_make(self):
434
+ super().check_can_make()
435
+
436
+ if cubegen is None:
437
+ raise File_maker_exception(self, "PySCF is not available")
438
+
439
+ def make_files(self):
440
+ """
441
+ Make the files referenced by this object.
442
+ """
443
+ if self.target_type == "density":
444
+ cubegen.density(self.mol, self.output, self.target.make_rdm1(), nx=self.npts, ny=self.npts, nz=self.npts)
445
+
446
+ elif self.target_type == "orbital":
447
+ cubegen.orbital(self.mol, self.output, self.target, nx=self.npts, ny=self.npts, nz=self.npts)
448
+
449
+ else:
450
+ raise ValueError("Unrecognised 'target_type': {}".format(self.target_type))
451
+
452
+ if self.sanitize:
453
+ sanitize_modern_cubes(self.output)
digichem/file/types.py CHANGED
@@ -74,6 +74,7 @@ gaussian_cube_file = File_type("cube", "gaussian", [".cub", ".cube"])
74
74
 
75
75
  orca_gbw_file = File_type("gbw", "orca", [".gbw"])
76
76
  orca_density_file = File_type("density", "orca", [".densities"])
77
+ orca_density_info_file = File_type("density-info", "orca", [".densitiesinfo"])
77
78
 
78
79
  # A list of all our known types.
79
80
  known_types = [log_file, gaussian_chk_file, gaussian_fchk_file, gaussian_rwf_file, gaussian_cube_file, orca_gbw_file]
digichem/image/render.py CHANGED
@@ -10,12 +10,17 @@ import subprocess
10
10
  import yaml
11
11
  from PIL import Image
12
12
  import math
13
+ import numpy
14
+ import warnings
15
+ import json
13
16
 
17
+ import digichem.log
14
18
  from digichem.exception.base import File_maker_exception
15
19
  from digichem.file.base import File_converter
16
20
  from digichem.image.base import Cropable_mixin
17
21
  from digichem.datas import get_resource
18
22
 
23
+
19
24
  class Render_maker(File_converter, Cropable_mixin):
20
25
  """
21
26
  ABC for classes that make 3D renders from cube files.
@@ -35,6 +40,7 @@ class Render_maker(File_converter, Cropable_mixin):
35
40
  resolution = 1024,
36
41
  also_make_png = True,
37
42
  isovalue = 0.2,
43
+ num_cpu = 1,
38
44
  **kwargs):
39
45
  """
40
46
  Constructor for Image_maker objects.
@@ -46,6 +52,7 @@ class Render_maker(File_converter, Cropable_mixin):
46
52
  :param resolution: The max width or height of the rendered images in pixels.
47
53
  :param also_make_png: If True, additional images will be rendered in PNG format. This option is useful to generate higher quality images alongside more portable formats.
48
54
  :param isovalue: The isovalue to use for rendering isosurfaces. Has no effect when rendering only atoms.
55
+ :param num_cpu: The number of CPUs for multithreading.
49
56
  :param blender_executable:
50
57
  """
51
58
  super().__init__(*args, input_file = cube_file, **kwargs)
@@ -61,6 +68,7 @@ class Render_maker(File_converter, Cropable_mixin):
61
68
  self.target_resolution = resolution
62
69
  self.also_make_png = also_make_png
63
70
  self.isovalue = isovalue
71
+ self.num_cpu = num_cpu
64
72
 
65
73
  # TODO: These.
66
74
  self.primary_colour = "red"
@@ -132,11 +140,13 @@ class Batoms_renderer(Render_maker):
132
140
  rotations = None,
133
141
  auto_crop = True,
134
142
  resolution = 1024,
135
- render_samples = 256,
143
+ render_samples = 32,
144
+ stack = 3,
136
145
  also_make_png = True,
137
146
  isovalue = 0.02,
138
147
  blender_executable = None,
139
148
  cpus = 1,
149
+ num_cpu = 1,
140
150
  perspective = "perspective",
141
151
  logging = False,
142
152
  **kwargs):
@@ -149,12 +159,20 @@ class Batoms_renderer(Render_maker):
149
159
  :param auto_crop: If False, images will not have excess white space cropped.
150
160
  :param resolution: The max width or height of the rendered images in pixels.
151
161
  :param render_samples: The number of render samples, more results in longer render times but higher quality image.
162
+ :param stack: The number of copies of the image to composite together to avoid transparency artifacts.
152
163
  :param also_make_png: If True, additional images will be rendered in PNG format. This option is useful to generate higher quality images alongside more portable formats.
153
164
  :param isovalue: The isovalue to use for rendering isosurfaces. Has no effect when rendering only atoms.
154
165
  :param blender_executable: Bath to the blender executable (can be None to use a default).
155
- :param cpus: Number of parallel threads to render with.
166
+ :param cpus: DEPREACTED: Number of parallel threads to render with (use num_cpu instead)
167
+ :param num_cpu: Number of parallel threads to render with.
156
168
  :param perspective: Perspective mode (orthographic or perspective)
157
169
  """
170
+ if cpus != 1:
171
+ warnings.warn("cpus is deprecated, use num_cpu instead", DeprecationWarning)
172
+
173
+ if num_cpu == 1 and cpus != 1:
174
+ num_cpu = cpus
175
+
158
176
  super().__init__(
159
177
  *args,
160
178
  cube_file = cube_file,
@@ -163,14 +181,15 @@ class Batoms_renderer(Render_maker):
163
181
  resolution = resolution,
164
182
  also_make_png = also_make_png,
165
183
  isovalue = math.fabs(isovalue),
184
+ num_cpu = num_cpu,
166
185
  **kwargs
167
186
  )
168
187
 
169
188
  # Blender specific options.
170
189
  self.render_samples = render_samples
171
- self.cpus = cpus
172
190
  self.perspective = perspective
173
-
191
+ self.stack = stack
192
+
174
193
  self.logging = logging
175
194
 
176
195
  # Use explicit blender location if given.
@@ -182,7 +201,7 @@ class Batoms_renderer(Render_maker):
182
201
  self.blender_executable = get_resource('data/batoms/blender/blender')
183
202
 
184
203
  @classmethod
185
- def from_options(self, output, *, cube_file = None, rotations = None, cpus = None, options, **kwargs):
204
+ def from_options(self, output, *, cube_file = None, rotations = None, cpus = None, num_cpu = 1, options, **kwargs):
186
205
  """
187
206
  Constructor that takes a dictionary of config like options.
188
207
  """
@@ -193,17 +212,20 @@ class Batoms_renderer(Render_maker):
193
212
  auto_crop = options['render']['auto_crop'],
194
213
  resolution = options['render']['resolution'],
195
214
  render_samples = options['render']['batoms']['render_samples'],
215
+ stack = options['render']['batoms']['stacking'],
196
216
  isovalue = options['render'][self.options_name]['isovalue'],
197
217
  use_existing = options['render']['use_existing'],
198
218
  dont_modify = not options['render']['enable_rendering'],
199
219
  blender_executable = options['render']['batoms']['blender'],
220
+ # Deprecated...
200
221
  cpus = cpus if cpus is not None else options['render']['batoms']['cpus'],
222
+ num_cpu = num_cpu,
201
223
  perspective = options['render']['batoms']['perspective'],
202
224
  logging = options['logging']['render_logging'],
203
225
  **kwargs
204
226
  )
205
227
 
206
- def blender_signature(self, output, resolution, samples, orientation, padding = 1.0):
228
+ def blender_signature(self, *targets, padding = 1.0):
207
229
  """
208
230
  The signature passed to subprocess.run used to call Blender. Inheriting classes should write their own implementation.
209
231
  """
@@ -217,44 +239,77 @@ class Batoms_renderer(Render_maker):
217
239
  "--",
218
240
  # Script specific args.
219
241
  f"{self.input_file}",
220
- f"{output}",
242
+ # f"{output}",
221
243
  # Keywords.
222
- "--cpus", f"{self.cpus}",
223
- "--orientation", "{}".format(orientation[0]), "{}".format(orientation[1]), "{}".format(orientation[2]),
224
- "--resolution", f"{resolution}",
225
- "--render-samples", f"{samples}",
244
+ "--cpus", f"{self.num_cpu}",
245
+ # "--orientation", "{}".format(orientation[0]), "{}".format(orientation[1]), "{}".format(orientation[2]),
246
+ # "--resolution", f"{resolution}",
247
+ # "--render-samples", f"{samples}",
226
248
  "--perspective", f"{self.perspective}",
227
249
  "--padding", f"{padding}",
228
250
  "--rotations",
229
251
  ]
252
+ for orientation, resolution, samples, mini_file_name in targets:
253
+ args.extend([
254
+ "--multi",
255
+ "{}".format(orientation[0]), "{}".format(orientation[1]), "{}".format(orientation[2]),
256
+ f"{resolution}",
257
+ f"{samples}",
258
+ mini_file_name
259
+ ])
260
+
230
261
  # Add rotations.
231
262
  for rotation in self.rotations:
232
- args.append(yaml.safe_dump(rotation))
263
+ args.append(json.dumps(rotation))
233
264
 
234
265
  return args
235
266
 
236
- def run_blender(self, output, resolution, samples, orientation):
267
+ #def run_blender(self, output, resolution, samples, orientation):
268
+
269
+ def run_blender(self, *targets):
270
+ """
271
+ Render a (number of) images with blender.
272
+
273
+ :param samples: How many render samples to use.
274
+ :param *targets: Images to render, each is a tuple if (orientation, resolution, samples, path).
275
+ """
237
276
  # Render with batoms.
238
277
  env = dict(os.environ)
239
278
 
240
279
  # Disabling the user dir helps prevent conflicting installs of certain packages
241
280
  env["PYTHONNOUSERSITE"] = "1"
242
281
  #env["PYTHONPATH"] = ":" + env["PYTHONPATH"]
282
+ blender_process = None
243
283
 
244
284
  # Run Blender, which renders our image for us.
245
285
  try:
246
- subprocess.run(
247
- self.blender_signature(output, resolution, samples, orientation),
286
+ blender_process = subprocess.run(
287
+ self.blender_signature(*targets),
248
288
  stdin = subprocess.DEVNULL,
249
- stdout = subprocess.DEVNULL if not self.logging else None,
289
+ stdout = subprocess.PIPE if not self.logging else None,
250
290
  stderr = subprocess.STDOUT,
251
291
  universal_newlines = True,
252
292
  check = True,
253
293
  env = env,
254
294
  )
295
+
296
+ for target in targets:
297
+ if not target[3].exists():
298
+ raise File_maker_exception(self, "Could not find render file '{}'".format(target[3]))
299
+
255
300
  except FileNotFoundError:
256
301
  raise File_maker_exception(self, "Could not locate blender executable '{}'".format(self.blender_executable))
257
302
 
303
+ except subprocess.CalledProcessError as e:
304
+ if not self.logging:
305
+ digichem.log.get_logger().error("Blender did not exit successfully, dumping output:\n{}".format(e.stdout))
306
+
307
+ except Exception:
308
+ if not self.logging and blender_process is not None:
309
+ digichem.log.get_logger().error("Blender did not exit successfully, dumping output:\n{}".format(blender_process.stdout))
310
+
311
+ raise
312
+
258
313
 
259
314
  def make_files(self):
260
315
  """
@@ -263,23 +318,42 @@ class Batoms_renderer(Render_maker):
263
318
  The new image will be written to file.
264
319
  """
265
320
  # TODO: This mechanism is clunky and inefficient if only one image is needed because its based off the old VMD renderer. With batoms we can do much better.
266
- for image_name, orientation in [
267
- ('x0y0z0', (0,0,1)),
268
- ('x90y0z0', (1,0,0)),
269
- ('x0y90z0', (0,1,0)),
270
- ('x45y45z45',(1,1,1))
271
- ]:
272
- image_path = self.file_path[image_name]
273
- try:
274
- # First we'll render a test image at a lower resolution. We'll then crop it, and use the decrease in final resolution to know how much bigger we need to render in our final image to hit our target resolution.
275
- # Unless of course auto_crop is False, in which case we use our target resolution immediately.
276
- resolution = self.test_resolution if self.auto_crop else self.target_resolution
277
- samples = self.test_samples if self.auto_crop else self.render_samples
278
- self.run_blender(image_path.with_suffix(".tmp.png"), resolution, samples, orientation)
279
-
280
- if self.auto_crop:
281
- # Load the test image and autocrop it.
282
- with Image.open(image_path.with_suffix(".tmp.png"), "r") as test_im:
321
+ angles = {
322
+ "x0y0z0": [
323
+ (0,0,0),
324
+ self.test_resolution if self.auto_crop else self.target_resolution,
325
+ self.test_samples if self.auto_crop else self.render_samples,
326
+ self.file_path['x0y0z0'].with_suffix(".tmp.png")
327
+ ],
328
+ "x90y0z0": [
329
+ (1.5708, 0, 0),
330
+ self.test_resolution if self.auto_crop else self.target_resolution,
331
+ self.test_samples if self.auto_crop else self.render_samples,
332
+ self.file_path['x90y0z0'].with_suffix(".tmp.png")
333
+ ],
334
+ "x0y90z0": [
335
+ (0, 1.5708, 0),
336
+ self.test_resolution if self.auto_crop else self.target_resolution,
337
+ self.test_samples if self.auto_crop else self.render_samples,
338
+ self.file_path['x0y90z0'].with_suffix(".tmp.png")
339
+ ],
340
+ "x45y45z45": [
341
+ (0.785398, 0.785398, 0.785398),
342
+ self.test_resolution if self.auto_crop else self.target_resolution,
343
+ self.test_samples if self.auto_crop else self.render_samples,
344
+ self.file_path['x45y45z45'].with_suffix(".tmp.png")
345
+ ],
346
+ }
347
+
348
+ try:
349
+ # First we'll render a test image at a lower resolution. We'll then crop it, and use the decrease in final resolution to know how much bigger we need to render in our final image to hit our target resolution.
350
+ # Unless of course auto_crop is False, in which case we use our target resolution immediately.
351
+ self.run_blender(*list(angles.values()))
352
+
353
+ if self.auto_crop:
354
+ # Load the test image and autocrop it.
355
+ for angle, target in angles.items():
356
+ with Image.open(target[3], "r") as test_im:
283
357
  small_test_im = self.auto_crop_image(test_im)
284
358
 
285
359
  # Get the cropped size. We're interested in the largest dimension, as this is what we'll output as.
@@ -287,15 +361,28 @@ class Batoms_renderer(Render_maker):
287
361
 
288
362
  # From this we can work out the ratio between our true resolution and the resolution we've been asked for.
289
363
  resolution_ratio = cropped_resolution / self.test_resolution
364
+
365
+ # Update the target properties.
366
+ angles[angle] = [
367
+ # Orientation is the same.
368
+ angles[angle][0],
369
+ # New resolution.
370
+ int(self.target_resolution / resolution_ratio),
371
+ # New samples.
372
+ self.render_samples,
373
+ # New filename,
374
+ angles[angle][3]
375
+ ]
290
376
 
291
- self.run_blender(image_path.with_suffix(".tmp.png"), int(self.target_resolution / resolution_ratio), self.render_samples, orientation)
292
-
293
- except Exception:
294
- raise File_maker_exception(self, "Error in blender rendering")
377
+ self.run_blender(*list(angles.values()))
295
378
 
296
- # Convert to a better set of formats.
297
- # Open the file we just rendered.
298
- with Image.open(image_path.with_suffix(".tmp.png"), "r") as im:
379
+ except Exception:
380
+ raise File_maker_exception(self, "Error in blender rendering")
381
+
382
+ # Convert to a better set of formats.
383
+ # Open the files we just rendered.
384
+ for angle, target in angles.items():
385
+ with Image.open(target[3], "r") as im:
299
386
 
300
387
  # If we've been asked to autocrop, do so.
301
388
  if self.auto_crop:
@@ -305,10 +392,21 @@ class Batoms_renderer(Render_maker):
305
392
  raise File_maker_exception(self, "Error in post-rendering auto-crop")
306
393
  else:
307
394
  cropped_image = im
395
+
396
+ # Transparency stacking.
397
+ # This 'hack' takes several copies of the same transparent image and layers them atop each other.
398
+ # This avoids problems with isosurfaces being too transparent with respect to the background (and
399
+ # thus loosing definition).
400
+ stacked = cropped_image.copy()
401
+
402
+ for _ in range(self.stack):
403
+ stacked.alpha_composite(cropped_image)
404
+
405
+ cropped_image = stacked
308
406
 
309
407
  # Save as a higher quality png if we've been asked to.
310
408
  if self.also_make_png:
311
- cropped_image.save(self.file_path[image_name + "_big"])
409
+ cropped_image.save(self.file_path[angle + "_big"])
312
410
 
313
411
  # Remove transparency, which isn't supported by JPEG (which is essentially the only format we write here).
314
412
  # TODO: Check if the output format can support transparency or not.
@@ -317,10 +415,10 @@ class Batoms_renderer(Render_maker):
317
415
  cropped_image = new_image.convert('RGB')
318
416
 
319
417
  # Now save in our main output format.
320
- cropped_image.save(image_path)
418
+ cropped_image.save(self.file_path[angle])
321
419
 
322
420
  # And delete the old .png.
323
- os.remove(image_path.with_suffix(".tmp.png"))
421
+ os.remove(target[3])
324
422
 
325
423
 
326
424
  class Structure_image_maker(Batoms_renderer):
@@ -540,8 +638,9 @@ class Dipole_image_maker(Structure_image_maker):
540
638
  sig = super().blender_signature(output, resolution, samples, orientation)
541
639
  sig.append("--dipoles")
542
640
  for dipole in self.get_dipoles():
543
- sig.append(yaml.safe_dump(dipole))
544
- sig.extend(["--alpha", "0.5"])
641
+ #sig.append(yaml.safe_dump(dipole))
642
+ sig.append(json.dumps(dipole))
643
+ sig.extend(["--alpha", "0.75"])
545
644
  return sig
546
645
 
547
646