-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcreate_wire_reconstruction.py
More file actions
1392 lines (1201 loc) · 64.4 KB
/
Copy pathcreate_wire_reconstruction.py
File metadata and controls
1392 lines (1201 loc) · 64.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import datetime
import glob
import logging
import os
import urllib.parse
import dash
import dash_bootstrap_components as dbc
from dash import html, dcc, Input, Output, State, set_props
from dash.exceptions import PreventUpdate
from sqlalchemy.orm import Session
import laue_portal.database.db_utils as db_utils
import laue_portal.database.db_schema as db_schema
import laue_portal.components.navbar as navbar
from laue_portal.database.db_utils import get_catalog_data, get_data_from_id, remove_root_path_prefix, parse_parameter, parse_IDnumber
from laue_portal.components.wire_recon_form import wire_recon_form, set_wire_recon_form_props
from laue_portal.components.form_base import _field
from laue_portal.components.validation_alerts import validation_alerts
from laue_portal.processing.redis_utils import enqueue_wire_reconstruction, STATUS_REVERSE_MAPPING
from laue_portal.config import DEFAULT_VARIABLES
from laue_portal.pages.validation_helpers import (
apply_validation_highlights,
update_validation_alerts,
add_validation_message,
safe_float,
safe_int,
validate_field_value,
validate_numeric_range,
validate_file_exists,
validate_directory_exists,
get_num_inputs_from_fields
)
from laue_portal.pages.callback_registrars import (
register_update_path_fields_callback,
register_load_file_indices_callback,
register_check_filenames_callback,
_merge_field_values
)
from laue_portal.utilities.srange import srange
import laue_portal.database.session_utils as session_utils
logger = logging.getLogger(__name__)
def resolve_path_with_root(path, root_path):
"""
Resolve a path, using root_path only if the path is relative.
If path is absolute, return it as-is (overriding root_path).
Args:
path: The path to resolve (can be relative or absolute)
root_path: The root path to prepend if path is relative
Returns:
str: The resolved full path
"""
if not path:
return ""
# Check if path is absolute
if os.path.isabs(path):
# Path is absolute - use it directly, ignore root_path
return path
else:
# Path is relative - combine with root_path
return os.path.join(root_path, path.lstrip('/'))
def build_output_folder_template(scan_num_int, data_path):
"""
Build output folder template based on available IDs from database chain.
Only the final action ID remains as %d.
Parameters:
- scan_num_int: scanNumber (int or None)
- data_path: data path to use if scanNumber unknown
- root_path: root path
Returns:
- Output folder template path (relative, without root_path prefix)
"""
path_parts = ["analysis"]
# Add scan directory only if scanNumber is known
if scan_num_int is not None:
path_parts.append(f"scan_{scan_num_int}")
else:
# If scanNumber is unknown, use data_path for context
if data_path:
clean_data_path = data_path.strip('/')
path_parts.append(clean_data_path)
# Add final action placeholder for wire recon
path_parts.append("rec_%d")
path_parts.append("data")
return os.path.join(*path_parts)
JOB_DEFAULTS = {
"computer_name": 'example_computer',
"status": 0, #pending, running, finished, stopped
"priority": 0,
"submit_time": datetime.datetime.now(),
"start_time": datetime.datetime.now(),
"finish_time": datetime.datetime.now(),
}
WIRERECON_DEFAULTS = {
"scanNumber": 276994,
"geoFile": "Run1/geofiles/geoN_2023-04-06_03-07-11_cor6.xml",
"percent_brightest": 100,
"depth_start": -50,
"depth_end": 150,
"depth_resolution": 1,
"outputFolder": "analysis/scan_%d/rec_%d/data",#"wire_recons",
"scanPoints": "7",#"1", # String field for srange parsing
"wire_edges": "leading",
}
CATALOG_DEFAULTS = {
"filefolder": "tests/data/gdata",
"filenamePrefix": "HAs_long_laue1_",
}
# DEFAULT_VARIABLES = {
# "author": "",
# "notes": "",
# "root_path": "/net/s34data/export/s34data1/LauePortal/portal_workspace/",
# "num_threads": 35,
# "memory_limit_mb": 50000,
# "verbose": 1,
# }
dash.register_page(__name__)
layout = dbc.Container(
[html.Div([
navbar.navbar,
dcc.Location(id='url-create-wirerecon', refresh=False),
dbc.Alert(
"Hello! I am an alert",
id="alert-upload",
dismissable=True,
is_open=False,
),
dbc.Alert(
"Hello! I am an alert",
id="alert-submit",
dismissable=True,
is_open=False,
),
dbc.Alert(
"Scan data loaded successfully",
id="alert-scan-loaded",
dismissable=True,
is_open=False,
color="success",
),
html.Hr(),
dbc.Row(
[
dbc.Col(
html.H3(id="wirerecon-title", children="New Wire Reconstruction"),
width="auto", # shrink to content
),
dbc.Col(
dbc.Button(
"Validate",
id="wirerecon-validate-btn",
color="secondary",
style={"minWidth": 150, "maxWidth": "150px", "width": "100%"},
),
width="auto",
className="ms-3", # small gap from title
),
dbc.Col(
dbc.Button(
"Submit",
id="submit_wire",
color="primary",
style={"minWidth": 150, "maxWidth": "150px", "width": "100%"},
),
width="auto",
className="ms-2",
),
],
className="g-2", # gutter between cols
justify="center", # CENTER horizontally
align="center", # CENTER vertically
),
html.Hr(),
validation_alerts,
dbc.Row([
dbc.Col(
dbc.Button(
"Set from ...",
id="upload-wireconfig",
color="secondary",
style={"minWidth": 150, "maxWidth": "150px", "width": "100%"},
),
width="auto",
),
dbc.Col(
_field("Author", "author", kwargs={"type": "text", "placeholder": "Required! Enter author or Tag for the reconstruction"}),
width="auto",
style={"minWidth": "300px", "flexGrow": 1},
),
],
justify="start",
className="mb-0",
),
# html.Hr(),
wire_recon_form,
# dcc.Store(id="next-wire-recon-id"),
dcc.Store(id="wirerecon-data-loaded-signal"),
],
)
],
className='dbc',
fluid=True
)
def validate_wire_reconstruction_inputs(ctx):
"""
Validate specified wire reconstruction inputs using callback context.
Parameters:
- ctx: dash.callback_context containing states_list with field IDs and values
Returns:
validation_result (dict): {
'errors': dict mapping field_name to list of error messages,
'warnings': dict mapping field_name to list of warning messages,
'successes': dict mapping field_name to empty string (for fields that passed)
}
"""
# Initialize validation result dict
validation_result = {
'errors': {},
'warnings': {},
'successes': {}
}
# Dictionary to store parsed field value lists
parsed_fields = {}
# Hard-coded list of field IDs to validate (excludes 'notes')
all_field_ids = [
'data_path',
'filenamePrefix',
'scanPoints',
'geoFile',
'depth_start',
'depth_end',
'depth_resolution',
'percent_brightest',
'outputFolder',
'root_path',
'IDnumber', # Replaced scanNumber with IDnumber
# 'scanNumber', # Now parsed from IDnumber
'author',
]
# Optional parameters list - these fields are not required
optional_params = [
'scanPoints',
'scanNumber',
]
# Create database session for catalog validation
session = Session(session_utils.get_engine())
# Extract field values from callback context using the hard-coded field list
# ctx.states is a dict with format {'component_id.prop_name': value}
all_fields = {}
for key, value in ctx.states.items():
# Extract component_id from 'component_id.prop_name'
component_id = key.split('.')[0]
# Only include fields in our validation list
if component_id in all_field_ids:
all_fields[component_id] = value
# Determine num_inputs from longest semicolon-separated list across all fields
num_inputs = get_num_inputs_from_fields(all_fields)
# Extract individual field values
root_path = all_fields.get('root_path', '')
IDnumber = all_fields.get('IDnumber', '')
# Validate root_path directory exists
if not root_path:
add_validation_message(validation_result, 'errors', 'root_path')
elif not os.path.exists(root_path):
add_validation_message(validation_result, 'errors', 'root_path',
custom_message="Root Path does not exist")
else: #Added to pass over in later loop over all_fields
parsed_fields['root_path'] = root_path
add_validation_message(validation_result, 'successes', 'root_path')
# Parse IDnumber to get scanNumber, wirerecon_id
# Mark IDnumber as handled so the general loop skips it
parsed_fields['IDnumber'] = IDnumber
if IDnumber:
try:
id_dict = parse_IDnumber(IDnumber, session)
# Add parsed IDs to parsed_fields for use in validation
for key, value in id_dict.items():
if value is not None:
parsed_fields[key] = parse_parameter(value, num_inputs)
add_validation_message(validation_result, 'successes', 'IDnumber')
except ValueError as e:
error_message = str(e)
# Check if this is a "not found" error - treat as warning instead of error
if "not found in database" in error_message:
add_validation_message(validation_result, 'warnings', 'IDnumber',
custom_message=f"ID Number warning: {error_message}. This will create an unlinked wire reconstruction.")
else:
# Other parsing errors (invalid format, etc.) remain as errors
add_validation_message(validation_result, 'errors', 'IDnumber',
custom_message=f"ID Number parsing error: {error_message}")
else:
# IDnumber is optional - if not provided, show warning about unlinked reconstruction
add_validation_message(validation_result, 'warnings', 'IDnumber',
custom_message="No ID Number provided. This will create an unlinked wire reconstruction.")
# Validate all other fields by iterating over all_fields
for field_name, field_value in all_fields.items():
# Skip already handled fields
if field_name in parsed_fields: #{'root_path', 'data_path'}
continue
# Check 1: Is it missing/empty?
is_missing = False
if field_name in ['depth_start', 'depth_end', 'depth_resolution', 'percent_brightest']:
# Numeric fields: check for None or empty string (0 is valid)
if field_value is None or field_value == '':
is_missing = True
else:
# Other fields: check for falsy values
if not field_value:
is_missing = True
if is_missing:
# Special case for scanNumber: only warning, not error
if field_name == 'scanNumber':
add_validation_message(validation_result, 'warnings', field_name, display_name="Scan Number")
continue # Skip parsing
else:
add_validation_message(validation_result, 'errors', field_name)
continue # Skip parsing if missing
# Check 2: Parse the field value
try:
parsed_list = parse_parameter(field_value, num_inputs)
except ValueError as e:
# Special case for scanNumber: only warning, not error
if field_name == 'scanNumber':
add_validation_message(validation_result, 'warnings', field_name,
custom_message=f"Scan Number parsing error: {str(e)}")
continue # Skip length check
else:
add_validation_message(validation_result, 'errors', field_name,
custom_message=f"%s parsing error: {str(e)}")
continue # Skip length check if parsing failed
# Check 3: Verify length matches num_inputs
if len(parsed_list) != num_inputs:
# Special case for scanNumber: only warning, not error
if field_name == 'scanNumber':
add_validation_message(validation_result, 'warnings', field_name,
custom_message=f"Scan Number count ({len(parsed_list)}) does not match number of inputs ({num_inputs})")
else:
add_validation_message(validation_result, 'errors', field_name,
custom_message=f"%s count ({len(parsed_list)}) does not match number of inputs ({num_inputs})")
# Store the parsed list in the dictionary
parsed_fields[field_name] = parsed_list
# Create outer wrapper with common parameters before the loop
def make_field_validator(validation_result, parsed_fields, optional_params):
"""Create a field validator with pre-filled common parameters"""
def validate_for_input(field_name, index, input_prefix, **kwargs):
return validate_field_value(
validation_result,
parsed_fields,
field_name,
index,
input_prefix,
optional_params=optional_params,
**kwargs
)
return validate_for_input
# Create the validator once before the loop
validate_for_input = make_field_validator(validation_result, parsed_fields, optional_params)
# Validate each input, skipping fields that failed global validation
for i in range(num_inputs):
input_prefix = f"Input {i+1}: " if num_inputs > 1 else ""
# Inner wrapper for this specific input
def validate_field(field_name, **kwargs):
return validate_for_input(field_name, i, input_prefix, **kwargs)
# 1. Validate ID integers (scanNumber)
# Convert scanNumber to integer if present
scan_num_int = None
if 'scanNumber' in parsed_fields:
current_scanNumber = validate_field('scanNumber', required=False, display_name="Scan Number")
if current_scanNumber is not None:
try:
scan_num_int = int(current_scanNumber)
except (ValueError, TypeError):
add_validation_message(
validation_result, 'warnings', 'scanNumber', input_prefix,
custom_message="Scan Number is not a valid integer"
)
# 2. Check if data files exist for this input (skip if root_path or data_path invalid)
if 'root_path' not in validation_result['errors'] and 'data_path' not in validation_result['errors']:
current_data_path = validate_field('data_path')
if current_data_path is not None:
# Warn if absolute path is being used (root_path will be ignored)
if os.path.isabs(current_data_path):
add_validation_message(
validation_result, 'warnings', 'data_path', input_prefix,
custom_message="Data Path is absolute - Root Path will be ignored"
)
current_full_data_path = resolve_path_with_root(current_data_path, root_path)
# Check if directory exists
if not os.path.exists(current_full_data_path):
add_validation_message(validation_result, 'errors', 'data_path', input_prefix,
custom_message="Data Path directory not found")
else:
# Validate against database if we have a valid scan number (uses ID validated above)
if scan_num_int is not None:
# Get catalog data for this scan
catalog_data = get_catalog_data(session, scan_num_int, root_path, CATALOG_DEFAULTS)
if catalog_data and catalog_data.get('data_path'):
catalog_full_data_path = os.path.join(root_path, catalog_data['data_path'].lstrip('/'))
if catalog_full_data_path != current_full_data_path:
add_validation_message(
validation_result, 'warnings', 'data_path', input_prefix,
custom_message=f"Catalog entry for Scan Number {scan_num_int} has different path ({catalog_data['data_path']})"
)
else:
# No catalog entry found for this scan number
add_validation_message(
validation_result, 'warnings', 'scanNumber', input_prefix,
custom_message=f"Catalog entry not found for Scan Number {scan_num_int}"
)
# Check if directory contains any files
all_files = [f for f in os.listdir(current_full_data_path) if os.path.isfile(os.path.join(current_full_data_path, f))]
if not all_files:
add_validation_message(validation_result, 'errors', 'data_path', input_prefix,
custom_message="Data Path directory contains no files")
else:
# Get filename prefix
current_filename_prefix_str = validate_field('filenamePrefix', display_name="Filename Prefix")
if current_filename_prefix_str is not None:
current_filename_prefix = [s.strip() for s in current_filename_prefix_str.split(',')] if current_filename_prefix_str else []
# Check for actual files using glob - pinpoint which field has the error
for current_filename_prefix_i in current_filename_prefix:
# Check if ANY files match this prefix pattern (without scan point substitution)
prefix_pattern = os.path.join(current_full_data_path, current_filename_prefix_i.replace('%d', '*'))
prefix_matches = glob.glob(prefix_pattern + '*')
if not prefix_matches:
add_validation_message(validation_result, 'errors', 'filenamePrefix', input_prefix,
custom_message=f"No files match Filename prefix pattern '{current_filename_prefix_i}'")
else:
# Get scan points (optional field)
current_scanPoints = validate_field('scanPoints', display_name="Scan Points", required=False)
if current_scanPoints is not None:
try:
scanPoints_srange = srange(current_scanPoints)
scanPoint_nums = scanPoints_srange.list()
except Exception as e:
add_validation_message(validation_result, 'errors', 'scanPoints', input_prefix,
custom_message="Scan Points entry has invalid format")
continue
# Collect missing scan points for this prefix
missing_scanpoints = []
for scanPoint_num in scanPoint_nums:
file_str = current_filename_prefix_i % scanPoint_num if '%d' in current_filename_prefix_i else current_filename_prefix_i
scanpoint_pattern = os.path.join(current_full_data_path, file_str)
scanpoint_matches = glob.glob(scanpoint_pattern + '*')
if not scanpoint_matches:
missing_scanpoints.append(str(scanPoint_num))
# If there are missing scan points, add a single error message for this prefix
if missing_scanpoints:
# Limit the number of scan points shown
if len(missing_scanpoints) <= 5:
scanpoints_str = ", ".join(missing_scanpoints)
else:
scanpoints_str = ", ".join(missing_scanpoints[:5]) + f", ... and {len(missing_scanpoints) - 5} more"
add_validation_message(
validation_result, 'errors', 'scanPoints', input_prefix,
custom_message=f"Missing files for Filename prefix '{current_filename_prefix_i}' (Scan Points: {scanpoints_str})"
)
# 3. Check if output folder already exists for this input (skip if root_path invalid)
# Note: We cannot validate this properly if outputFolder contains %d placeholders
# because we don't know the scan number or wirerecon_id at validation time.
# This check is skipped if %d is present in the path.
current_outputFolder = validate_field('outputFolder', display_name="Output Folder")
if current_outputFolder is not None:
if 'root_path' not in validation_result['errors']:
# Warn if absolute path is being used (root_path will be ignored)
if os.path.isabs(current_outputFolder):
add_validation_message(
validation_result, 'warnings', 'outputFolder', input_prefix,
custom_message="Output Folder is absolute - Root Path will be ignored"
)
if '%d' not in current_outputFolder:
full_output_path = resolve_path_with_root(current_outputFolder, root_path)
if os.path.exists(full_output_path):
add_validation_message(validation_result, 'warnings', 'outputFolder', input_prefix,
custom_message="Output Folder already exists")
# 4. Check if geometry file exists for this input (skip if root_path invalid)
current_geoFile = validate_field('geoFile', display_name="Geometry File")
if current_geoFile is not None:
if 'root_path' not in validation_result['errors']:
# Warn if absolute path is being used (root_path will be ignored)
if os.path.isabs(current_geoFile):
add_validation_message(
validation_result, 'warnings', 'geoFile', input_prefix,
custom_message="Geometry File is absolute - Root Path will be ignored"
)
full_geo_path = resolve_path_with_root(current_geoFile, root_path)
if not os.path.exists(full_geo_path):
add_validation_message(validation_result, 'errors', 'geoFile', input_prefix,
custom_message="Geometry File not found")
# 5. Validate depth parameters for this input using the universal helper
depth_start_val = validate_field('depth_start', converter=safe_float)
depth_end_val = validate_field('depth_end', converter=safe_float)
depth_resolution_val = validate_field('depth_resolution', converter=safe_float)
# Initialize depth_span as None (will be calculated if both start and end are valid)
depth_span = None
# Check start < end (only if both values are valid)
if depth_start_val is not None and depth_end_val is not None:
if depth_start_val >= depth_end_val:
add_validation_message(validation_result, 'errors', 'depth_start', input_prefix,
custom_message="Depth Start must be less than Depth End")
add_validation_message(validation_result, 'errors', 'depth_end', input_prefix,
custom_message="Depth Start must be less than Depth End")
# Calculate depth_span once (used in multiple checks below)
depth_span = depth_end_val - depth_start_val
# Warning: large depth range
if depth_span > 500:
add_validation_message(validation_result, 'warnings', 'depth_start', input_prefix,
custom_message=f"Total depth range ({depth_span} µm) is large (> 500 µm)")
add_validation_message(validation_result, 'warnings', 'depth_end', input_prefix,
custom_message=f"Total depth range ({depth_span} µm) is large (> 500 µm)")
# Check resolution value (only needs depth_resolution to be valid)
if depth_resolution_val is not None:
# Error: resolution must be positive
if depth_resolution_val <= 0:
add_validation_message(validation_result, 'errors', 'depth_resolution', input_prefix,
custom_message="Depth Resolution must be positive")
# Warning: resolution too small
elif depth_resolution_val < 0.1:
add_validation_message(validation_result, 'warnings', 'depth_resolution', input_prefix,
custom_message=f"Depth Resolution ({depth_resolution_val} µm) is very small (< 0.1 µm)")
# Check resolution < range (needs ALL THREE to be valid, and no prior errors on depth_resolution)
if 'depth_resolution' not in validation_result['errors']:
if depth_span is not None:
# Check if resolution is less than range
if depth_resolution_val > abs(depth_span):
add_validation_message(validation_result, 'errors', 'depth_start', input_prefix,
custom_message=f"Depth Start: resolution ({depth_resolution_val} µm) must be ≤ depth range ({abs(depth_span)} µm)")
add_validation_message(validation_result, 'errors', 'depth_end', input_prefix,
custom_message=f"Depth End: resolution ({depth_resolution_val} µm) must be ≤ depth range ({abs(depth_span)} µm)")
add_validation_message(validation_result, 'errors', 'depth_resolution', input_prefix,
custom_message=f"Depth Resolution ({depth_resolution_val} µm) must be ≤ depth range ({abs(depth_span)} µm)")
# 6. Validate percent_brightest for this input
percent_val = validate_field('percent_brightest', converter=safe_float, display_name="Intensity Percentile")
if percent_val is not None:
if percent_val <= 0 or percent_val > 100:
add_validation_message(validation_result, 'errors', 'percent_brightest', input_prefix,
custom_message="Intensity Percentile must be between 0 and 100")
# Add successes for fields that passed all validations
# Only add to successes if the field has neither errors nor warnings
for field_name in all_field_ids:
if field_name not in validation_result['errors'] and field_name not in validation_result['warnings']:
add_validation_message(validation_result, 'successes', field_name)
# Close database session
session.close()
return validation_result
"""
=======================
Callbacks
=======================
"""
@dash.callback(
Input('wirerecon-validate-btn', 'n_clicks'),
State('data_path', 'value'),
State('filenamePrefix', 'value'),
State('scanPoints', 'value'),
State('geoFile', 'value'),
State('depth_start', 'value'),
State('depth_end', 'value'),
State('depth_resolution', 'value'),
State('percent_brightest', 'value'),
State('outputFolder', 'value'),
State('root_path', 'value'),
State('IDnumber', 'value'), # Replaced scanNumber with IDnumber
# State('scanNumber', 'value'), # Old - now parsed from IDnumber
State('author', 'value'),
prevent_initial_call=True,
)
def validate_inputs(
n_clicks,
data_path,
filenamePrefix,
scanPoints,
geoFile,
depth_start,
depth_end,
depth_resolution,
percent_brightest,
outputFolder,
root_path,
IDnumber, # Replaced scanNumber with IDnumber
# scanNumber, # Old - now parsed from IDnumber
author,
):
"""Handle Validate button click"""
# Get callback context
ctx = dash.callback_context
# Run validation using ctx
validation_result = validate_wire_reconstruction_inputs(ctx)
# Apply field highlights using helper function
apply_validation_highlights(validation_result)
# Update validation alerts using helper function
update_validation_alerts(validation_result)
@dash.callback(
Input('submit_wire', 'n_clicks'),
State('root_path', 'value'),
State('IDnumber', 'value'), # Replaced scanNumber with IDnumber
# State('scanNumber', 'value'), # Old - now parsed from IDnumber
# User text
State('author', 'value'),
State('notes', 'value'),
# Recon constraints
State('geoFile', 'value'),
State('percent_brightest', 'value'),
State('wire_edges', 'value'),
# Depth parameters
State('depth_start', 'value'),
State('depth_end', 'value'),
State('depth_resolution', 'value'),
# Files
State('scanPoints', 'value'),
State('data_path', 'value'),
State('filenamePrefix', 'value'),
# Output
State('outputFolder', 'value'),
prevent_initial_call=True,
)
def submit_parameters(n,
root_path,
IDnumber, # Replaced scanNumber with IDnumber
# scanNumber, # Old - now parsed from IDnumber
# User text
author,
notes,
# Recon constraints
geometry_file,
percent_brightest,
wire_edges,
# Depth parameters
depth_start,
depth_end,
depth_resolution,
# Files
scanPoints,
data_path,
filenamePrefix,
# Output
output_folder,
):
"""
Submit parameters for wire reconstruction job(s).
Handles both single scan and pooled scan submissions.
"""
# Get callback context
ctx = dash.callback_context
# Run validation before submission using ctx
validation_result = validate_wire_reconstruction_inputs(ctx)
# Apply field highlights for all cases (error, warning, success)
apply_validation_highlights(validation_result)
# Update validation alerts using helper function
update_validation_alerts(validation_result)
# Extract to local variables for cleaner code
errors = validation_result['errors']
# warnings = validation_result['warnings']
# Block submission if there are errors
if errors:
set_props("alert-submit", {
'is_open': True,
'children': 'Submission blocked due to validation errors. Please fix the errors and try again.',
'color': 'danger'
})
return
# Parse IDnumber to get individual IDs
with Session(session_utils.get_engine()) as temp_session:
try:
id_dict = parse_IDnumber(IDnumber, temp_session)
scanNumber = id_dict.get('scanNumber')
# wirerecon_id = id_dict.get('wirerecon_id')
except ValueError as e:
set_props("alert-submit", {
'is_open': True,
'children': f'Invalid ID Number: {str(e)}',
'color': 'danger'
})
return
# Build all_submit_params from ctx.states (consistent with validation approach)
all_submit_params = {}
for key, value in ctx.states.items():
component_id = key.split('.')[0]
all_submit_params[component_id] = value
# Add parsed ID to the params dict
all_submit_params['scanNumber'] = scanNumber
# Determine num_inputs from longest semicolon-separated list across all fields
num_inputs = get_num_inputs_from_fields(all_submit_params)
# Parse all other parameters with num_inputs
try:
scanNumber_list = parse_parameter(scanNumber, num_inputs)
author_list = parse_parameter(author, num_inputs)
notes_list = parse_parameter(notes, num_inputs)
geoFile_list = parse_parameter(geometry_file, num_inputs)
percent_brightest_list = parse_parameter(percent_brightest, num_inputs)
wire_edges_list = parse_parameter(wire_edges, num_inputs)
depth_start_list = parse_parameter(depth_start, num_inputs)
depth_end_list = parse_parameter(depth_end, num_inputs)
depth_resolution_list = parse_parameter(depth_resolution, num_inputs)
scanPoints_list = parse_parameter(scanPoints, num_inputs)
data_path_list = parse_parameter(data_path, num_inputs)
filenamePrefix_list = parse_parameter(filenamePrefix, num_inputs)
outputFolder_list = parse_parameter(output_folder, num_inputs)
except ValueError as e:
# Error: mismatched lengths
set_props("alert-submit", {
'is_open': True,
'children': str(e),
'color': 'danger'
})
return
num_threads = DEFAULT_VARIABLES["num_threads"]
memory_limit_mb = DEFAULT_VARIABLES["memory_limit_mb"]
verbose = DEFAULT_VARIABLES["verbose"]
wirerecons_to_enqueue = []
# First loop: Create all database entries for each listed scanNumber
with Session(session_utils.get_engine()) as session:
try:
for i in range(num_inputs):
# Extract values for this scan
current_scanNumber = scanNumber_list[i]
current_output_folder = outputFolder_list[i]
current_geo_file = geoFile_list[i]
current_scanPoints = scanPoints_list[i]
# Convert scanNumber to integer if present
scan_num_int = None
if current_scanNumber:
try:
scan_num_int = int(current_scanNumber)
except (ValueError, TypeError) as e:
logger.error(f"Failed to convert scanNumber '{current_scanNumber}' to integer: {e}")
set_props("alert-submit", {
'is_open': True,
'children': f'Invalid scan number: {current_scanNumber}',
'color': 'danger'
})
# Convert relative paths to full paths using resolve_path_with_root
full_geometry_file = resolve_path_with_root(current_geo_file, root_path)
# Get next ID for this action
next_wirerecon_id = db_utils.get_next_id(session, db_schema.WireRecon)
# Now that we have the ID, format the output folder path by replacement of the final %d in the template
formatted_output_folder = current_output_folder % next_wirerecon_id
# Build full path using resolve_path_with_root
full_output_folder = resolve_path_with_root(formatted_output_folder, root_path)
# Create output directory if it doesn't exist
try:
os.makedirs(full_output_folder, exist_ok=True)
logger.info(f"Output directory: {full_output_folder}")
except Exception as e:
logger.error(f"Failed to create output directory {full_output_folder}: {e}")
set_props("alert-submit", {'is_open': True,
'children': f'Failed to create output directory: {str(e)}',
'color': 'danger'})
continue
JOB_DEFAULTS.update({'submit_time':datetime.datetime.now()})
JOB_DEFAULTS.update({'start_time':datetime.datetime.now()})
JOB_DEFAULTS.update({'finish_time':datetime.datetime.now()})
job = db_schema.Job(
computer_name=JOB_DEFAULTS['computer_name'],
status=JOB_DEFAULTS['status'],
priority=JOB_DEFAULTS['priority'],
submit_time=JOB_DEFAULTS['submit_time'],
start_time=JOB_DEFAULTS['start_time'],
finish_time=JOB_DEFAULTS['finish_time'],
)
session.add(job)
session.flush() # Get job_id without committing
job_id = job.job_id
# Create subjobs for parallel processing
# Parse scanPoints string using srange
scanPoints_srange = srange(current_scanPoints)
scanPointslen = scanPoints_srange.len()
for _ in range(scanPointslen):
subjob = db_schema.SubJob(
job_id=job_id,
computer_name=JOB_DEFAULTS['computer_name'],
status=STATUS_REVERSE_MAPPING["Queued"],
priority=JOB_DEFAULTS['priority']
)
session.add(subjob)
# Get filefolder and filenamePrefix
current_data_path = data_path_list[i]
current_filename_prefix_str = filenamePrefix_list[i]
current_filename_prefix = [s.strip() for s in current_filename_prefix_str.split(',')] if current_filename_prefix_str else []
# Build full path using resolve_path_with_root
current_full_data_path = resolve_path_with_root(current_data_path, root_path)
wirerecon = db_schema.WireRecon(
scanNumber=scan_num_int,
job_id=job_id,
filefolder=current_full_data_path,
filenamePrefix=current_filename_prefix,
# User text
author=author_list[i],
notes=notes_list[i],
# Recon constraints
geoFile=full_geometry_file, # Store full path in database
percent_brightest=percent_brightest_list[i],
wire_edges=wire_edges_list[i],
# Depth parameters
depth_start=depth_start_list[i],
depth_end=depth_end_list[i],
depth_resolution=depth_resolution_list[i],
# Compute parameters
num_threads=num_threads,
memory_limit_mb=memory_limit_mb,
# Files
scanPoints=current_scanPoints,
scanPointslen=scanPointslen,
# Output
outputFolder=full_output_folder, # Store full path in database
verbose=verbose,
)
# config_dict = db_utils.create_config_obj(wirerecon)
session.add(wirerecon)
wirerecons_to_enqueue.append({
"job_id": job_id,
"scanPoints": current_scanPoints,
"filefolder": current_full_data_path,
"filenamePrefix": current_filename_prefix,
"outputFolder": full_output_folder,
"geoFile": full_geometry_file,
"depth_start": depth_start_list[i],
"depth_end": depth_end_list[i],
"depth_resolution": depth_resolution_list[i],
"percent_brightest": percent_brightest_list[i],
"wire_edges": wire_edges_list[i],
"memory_limit_mb": memory_limit_mb,
"num_threads": num_threads,
"verbose": verbose,
"scanNumber": scan_num_int,
})
session.commit()
set_props("alert-submit", {'is_open': True,
'children': 'Entries Added to Database',
'color': 'success'})
except Exception as e:
session.rollback()
logger.error(f"Failed to create database entries: {e}")
set_props("alert-submit", {'is_open': True,
'children': f'Failed to create database entries: {str(e)}',
'color': 'danger'})
return
# Second loop: Enqueue jobs to Redis
for i, spec in enumerate(wirerecons_to_enqueue):
try:
# Extract values for this scan
full_data_path = spec["filefolder"]
current_filename_prefix = spec["filenamePrefix"]
scanPoints_srange = srange(spec["scanPoints"])
scanPoint_nums = scanPoints_srange.list()
# Prepare lists of input and output files for all subjobs
input_files = []
for current_filename_prefix_i in current_filename_prefix:
for scanPoint_num in scanPoint_nums:
# Apply %d formatting with scanPoint_num if prefix contains %d placeholder
file_str = current_filename_prefix_i % scanPoint_num if '%d' in current_filename_prefix_i else current_filename_prefix_i
input_file_pattern = os.path.join(full_data_path, file_str)
# Use glob to find matching files
matched_files = glob.glob(input_file_pattern + '*')
if not matched_files:
raise ValueError(f"No files found matching pattern: {input_file_pattern}")
input_files.extend(matched_files)
# Build output_files from input_files
output_files = [
os.path.join(spec["outputFolder"], os.path.splitext(os.path.basename(file))[0] + "_")
for file in input_files
]
# Enqueue the batch job with all files
depth_range = (spec["depth_start"], spec["depth_end"])
rq_job_id = enqueue_wire_reconstruction(
job_id=spec["job_id"],
input_files=input_files,
output_files=output_files,