astro-subframe-organizer 0.0.2

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.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +737 -0
  4. data/exe/astro-subframe-organizer +8 -0
  5. data/lib/astro_subframe_organizer/astrophoto.rb +122 -0
  6. data/lib/astro_subframe_organizer/commands/cleanup/empty_directories.rb +27 -0
  7. data/lib/astro_subframe_organizer/commands/cleanup/thumbnails.rb +38 -0
  8. data/lib/astro_subframe_organizer/commands/cleanup/unorganize.rb +25 -0
  9. data/lib/astro_subframe_organizer/commands/equipment_options.rb +30 -0
  10. data/lib/astro_subframe_organizer/commands/init.rb +54 -0
  11. data/lib/astro_subframe_organizer/commands/inspect.rb +101 -0
  12. data/lib/astro_subframe_organizer/commands/organize/base.rb +43 -0
  13. data/lib/astro_subframe_organizer/commands/organize/bias.rb +13 -0
  14. data/lib/astro_subframe_organizer/commands/organize/darks.rb +12 -0
  15. data/lib/astro_subframe_organizer/commands/organize/flats.rb +14 -0
  16. data/lib/astro_subframe_organizer/commands/organize/lights.rb +14 -0
  17. data/lib/astro_subframe_organizer/commands/raw/rename_from_exif.rb +45 -0
  18. data/lib/astro_subframe_organizer/commands/raw/revert_name.rb +28 -0
  19. data/lib/astro_subframe_organizer/commands/run.rb +28 -0
  20. data/lib/astro_subframe_organizer/commands/shared_options.rb +29 -0
  21. data/lib/astro_subframe_organizer/commands/version.rb +15 -0
  22. data/lib/astro_subframe_organizer/commands.rb +64 -0
  23. data/lib/astro_subframe_organizer/config.rb +140 -0
  24. data/lib/astro_subframe_organizer/equipment/camera.rb +14 -0
  25. data/lib/astro_subframe_organizer/equipment/filter.rb +14 -0
  26. data/lib/astro_subframe_organizer/equipment/telescope.rb +14 -0
  27. data/lib/astro_subframe_organizer/equipment_selector.rb +103 -0
  28. data/lib/astro_subframe_organizer/file_metadata.rb +135 -0
  29. data/lib/astro_subframe_organizer/file_set.rb +107 -0
  30. data/lib/astro_subframe_organizer/filename_parser.rb +106 -0
  31. data/lib/astro_subframe_organizer/filename_parsers/cr2_filename_parser.rb +67 -0
  32. data/lib/astro_subframe_organizer/filename_parsers/fits_filename_parser.rb +67 -0
  33. data/lib/astro_subframe_organizer/filename_parsers/fits_header_parser.rb +120 -0
  34. data/lib/astro_subframe_organizer/fits_organizer.rb +154 -0
  35. data/lib/astro_subframe_organizer/logging.rb +10 -0
  36. data/lib/astro_subframe_organizer/organizer.rb +125 -0
  37. data/lib/astro_subframe_organizer/path_builder.rb +55 -0
  38. data/lib/astro_subframe_organizer/path_builders/base_path_builder.rb +33 -0
  39. data/lib/astro_subframe_organizer/path_builders/bias_path_builder.rb +38 -0
  40. data/lib/astro_subframe_organizer/path_builders/dark_path_builder.rb +61 -0
  41. data/lib/astro_subframe_organizer/path_builders/flat_path_builder.rb +48 -0
  42. data/lib/astro_subframe_organizer/path_builders/light_path_builder.rb +53 -0
  43. data/lib/astro_subframe_organizer/utils/empty_directory_cleaner.rb +36 -0
  44. data/lib/astro_subframe_organizer/utils/exif_renamer.rb +132 -0
  45. data/lib/astro_subframe_organizer/utils/exposure_format.rb +25 -0
  46. data/lib/astro_subframe_organizer/utils/file_utils.rb +10 -0
  47. data/lib/astro_subframe_organizer/utils/fits_stripper.rb +115 -0
  48. data/lib/astro_subframe_organizer/utils/raw_stripper.rb +47 -0
  49. data/lib/astro_subframe_organizer/utils/thumbnail_cleaner.rb +26 -0
  50. data/lib/astro_subframe_organizer/utils/unorganizer.rb +54 -0
  51. data/lib/astro_subframe_organizer/version.rb +5 -0
  52. data/lib/astro_subframe_organizer.rb +103 -0
  53. metadata +182 -0
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ module FilenameParsers
5
+ # Parser for Canon CR2 (Canon Raw 2) format files from ASIAir Plus.
6
+ #
7
+ # CR2 files are Canon RAW image files that can be captured by cameras like the Canon T7.
8
+ # The filename structure is similar to FITS files when captured via ASIAir Plus.
9
+ #
10
+ # Expected filename format:
11
+ # Type_[Target]_[Mosaic]_Exposure_BinBinning_Camera_ISO/Gain_DateTime_CCDTemp_ImageIndex.cr2
12
+ #
13
+ # Example:
14
+ # Light_M42_1.0s_Bin1_T7_ISO100_20220508-120000_-10.0C_0001.cr2
15
+ #
16
+ # Returns a hash with keys identical to FitsFilenameParser, except:
17
+ # - :file_format (:cr2 instead of :fits)
18
+ #
19
+ # The CR2 format uses CCD-TEMP instead of ISO for temperature tracking since RAW files
20
+ # preserve more metadata from the camera hardware.
21
+ class CR2FilenameParser < FilenameParser
22
+ include Logging
23
+
24
+ # @return [Hash] Parsed metadata from CR2 filename
25
+ def parse # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
26
+ base_name = extract_base_name
27
+ parts = parse_parts(base_name)
28
+ result = {}
29
+
30
+ if @filename.start_with? 'IMG_'
31
+ logger.error(
32
+ 'Raw images must be renamed before organizing. Run `astro-subframe-organizer raw rename`, ' \
33
+ 'then try again.',
34
+ )
35
+ exit(1)
36
+ end
37
+
38
+ begin
39
+ result[:file_format] = :cr2
40
+ result[:path] = @path
41
+ result[:filename] = @filename
42
+
43
+ # If the file is already organized somewhere, get the information from its path.
44
+ result.merge!(extract_metadata_from_path)
45
+
46
+ result[:type] = parts.shift
47
+ result[:target] = parts.shift if result[:type] == 'Light'
48
+ result[:mosaic_pane] = parts.shift if parts.first&.match?(/\A\d+-\d+\z/)
49
+ result[:exposure] = parts.shift
50
+ result[:bin] = parts.shift.gsub('Bin', '') if parts.first&.start_with?('Bin')
51
+ result[:camera] = parts.shift if Equipment::Camera.all.include?(parts.first)
52
+ result[:iso] = parts.shift.gsub('ISO', '') if parts.first&.start_with?('ISO')
53
+ result[:gain] = parts.shift.gsub('gain', '') if parts.first&.start_with?('gain')
54
+ result[:created_at] = DateTime.strptime(parts.shift, FILENAME_DT_FORMAT)
55
+ result[:ccd_temp] = parts.shift
56
+ result[:image_index] = parts.shift
57
+ rescue StandardError => e
58
+ logger.error "Failed to parse #{@path}: #{e}"
59
+ ensure
60
+ logger.debug result
61
+ end
62
+
63
+ FileMetadata.from_parsed_data(result)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ module FilenameParsers
5
+ # Parser for FITS format files from ASIAir Plus.
6
+ #
7
+ # Expected filename format:
8
+ # Type_[Target]_[Mosaic]_Exposure_BinBinning_Camera_ISO/Gain_DateTime_CCDTemp_ImageIndex
9
+ #
10
+ # Example:
11
+ # Light_M42_1.0s_Bin1_T7_ISO100_20220508-120000_-10.0C_0001.fit
12
+ # Dark_30.0s_Bin1_T7_ISO100_20220508-120000_-10.0C_0001.fit
13
+ #
14
+ # Returns a hash with keys:
15
+ # - :type (Light, Dark, Flat, Bias)
16
+ # - :target (for Light files only)
17
+ # - :mosaic_pane (optional, if present)
18
+ # - :exposure (with units, e.g., "1.0s")
19
+ # - :bin (numeric string)
20
+ # - :camera (model name)
21
+ # - :iso (numeric string, if present)
22
+ # - :gain (numeric string, if ISO not present)
23
+ # - :created_at (DateTime object)
24
+ # - :ccd_temp (with units, e.g., "-10.0C")
25
+ # - :image_index (numeric string)
26
+ # - :file_format (:fits)
27
+ # - :path (full file path)
28
+ # - :filename (just the filename)
29
+ class FitsFilenameParser < FilenameParser
30
+ include Logging
31
+
32
+ # @return [Hash] Parsed metadata from FITS filename
33
+ def parse # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
34
+ base_name = extract_base_name
35
+ parts = parse_parts(base_name)
36
+ result = {}
37
+
38
+ begin
39
+ result[:file_format] = :fits
40
+ result[:path] = @path
41
+ result[:filename] = @filename
42
+
43
+ # If the file is already organized somewhere, get the information from its path.
44
+ result.merge!(extract_metadata_from_path)
45
+
46
+ result[:type] = parts.shift
47
+ result[:target] = parts.shift if result[:type] == 'Light'
48
+ result[:mosaic_pane] = parts.shift if parts.first&.match?(/\A\d+-\d+\z/)
49
+ result[:exposure] = parts.shift
50
+ result[:bin] = parts.shift.gsub('Bin', '') if parts.first&.start_with?('Bin')
51
+ result[:camera] = parts.shift if Equipment::Camera.all.include?(parts.first)
52
+ result[:iso] = parts.shift.gsub('ISO', '') if parts.first&.start_with?('ISO')
53
+ result[:gain] = parts.shift.gsub('gain', '') if parts.first&.start_with?('gain')
54
+ result[:created_at] = DateTime.strptime(parts.shift, FILENAME_DT_FORMAT)
55
+ result[:ccd_temp] = parts.shift
56
+ result[:image_index] = parts.shift
57
+ rescue StandardError => e
58
+ logger.error e
59
+ ensure
60
+ logger.debug result
61
+ end
62
+
63
+ FileMetadata.from_parsed_data(result)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fits_parser'
4
+
5
+ module AstroSubframeOrganizer
6
+ module FilenameParsers
7
+ # Parser to generate FileMetadata based on FITS headers and filename patterns.
8
+ class FitsHeaderParser < FilenameParser
9
+ include Logging
10
+ include AstroSubframeOrganizer::Utils::ExposureFormat
11
+
12
+ def headers
13
+ @headers ||= load_headers(path)
14
+ end
15
+
16
+ def [](key)
17
+ headers[key]
18
+ end
19
+
20
+ def parse # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
21
+ result = {
22
+ file_format: :fits,
23
+ path: @path,
24
+ filename: @filename,
25
+ }
26
+
27
+ # If the file is already organized somewhere, get the information from its path.
28
+ metadata_from_path = extract_metadata_from_path
29
+ result[:telescope] = metadata_from_path[:telescope] || header(:telescope)
30
+ result[:filter] = metadata_from_path[:filter] || header(:filter)
31
+ result[:dark_flat] = metadata_from_path[:dark_flat]
32
+
33
+ result[:type] = image_type
34
+ result[:target] = target if light_frame?
35
+ result[:exposure] = format_exposure(header(:exposure))
36
+ result[:bin] = header(:binning)
37
+ result[:camera] = header(:camera)
38
+ result[:gain] = header(:gain)
39
+ result[:iso] = header(:iso)
40
+ result[:created_at] = parse_date(header(:date_obs))
41
+ result[:ccd_temp] = format_temp(ccd_temperature)
42
+ result[:image_index] = parse_parts(extract_base_name).last
43
+ result[:rotation] = rotation_angle
44
+ result[:mosaic_pane] = mosaic_pane
45
+
46
+ FileMetadata.from_parsed_data(result)
47
+ rescue StandardError => e
48
+ logger.error "Failed to parse FITS headers for #{@filename}: #{e.message}"
49
+ FileMetadata.from_parsed_data(file_format: :fits, path: @path, filename: @filename)
50
+ ensure
51
+ logger.debug result
52
+ end
53
+
54
+ def target
55
+ header(:target) || parsed_from_filename[:target]
56
+ end
57
+
58
+ def mosaic_pane
59
+ # Not available in FITS headers for ASIAIR mosaic frames.
60
+ # Must be parsed from filename pattern: TARGET_ROW-COL_
61
+ parsed_from_filename[:mosaic_pane]
62
+ end
63
+
64
+ def image_type
65
+ header(:type)
66
+ end
67
+
68
+ def light_frame?
69
+ image_type == Astrophoto::LIGHT
70
+ end
71
+
72
+ def header(key)
73
+ keys = Config.fits_header_mappings[key.to_s] || []
74
+ keys.lazy.filter_map { |k| headers[k] }.first
75
+ end
76
+
77
+ def rotation_angle
78
+ header(:rotation)
79
+ end
80
+
81
+ def ccd_temperature
82
+ header(:temperature)
83
+ end
84
+
85
+ private
86
+
87
+ def load_headers(path)
88
+ hdu = FitsParser.open(path) do |parser|
89
+ parser.parse_hdus.find { |h| h[:header] }
90
+ end
91
+ raise "No HDU with headers found in #{path}" unless hdu
92
+
93
+ hdu[:header]
94
+ end
95
+
96
+ def parse_date(value)
97
+ return nil if value.nil?
98
+
99
+ DateTime.strptime(value, '%Y-%m-%dT%H:%M:%S.%6N')
100
+ rescue ArgumentError
101
+ DateTime.parse(value)
102
+ end
103
+
104
+ def parsed_from_filename
105
+ @parsed_from_filename ||= begin
106
+ parts = File.basename(@path, '.*').split('_')
107
+ pane_index = parts.index { |p| p.match?(/\A\d{1,2}-\d{1,2}\z/) }
108
+ {
109
+ target: pane_index ? parts[1...pane_index].join('_') : nil,
110
+ mosaic_pane: pane_index ? parts[pane_index] : nil,
111
+ }
112
+ end
113
+ end
114
+
115
+ def format_temp(temp)
116
+ format '%0.1fC', temp
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ # Main class responsible for interactively organizing FITS files based on their headers and user input.
5
+ class FitsOrganizer
6
+ include Logging
7
+
8
+ private attr_accessor :prompt, :path, :dry_run
9
+
10
+ def initialize(path = Dir.pwd, dry_run: nil)
11
+ self.prompt = AstroSubframeOrganizer.prompt
12
+ self.path = path
13
+ self.dry_run = dry_run
14
+ end
15
+
16
+ # Organizes dark files by ISO, BIN, CCD-TEMP, EXPOSURE, and MONTH to facilitate the creation of
17
+ # master darks that may have varying temperatures. This organization can be changed by updating
18
+ # Astrophoto#target_dir for the DARK type.
19
+ #
20
+ # If the file has an exposure of less than 10 seconds, you will be asked if it is a flat dark.
21
+ # If so, it will be organized into a folder that will match your corresponding flat files so that
22
+ # you can run WBPP with just your biases, flat darks, and flats using the grouping keywords
23
+ # FLATSET, BIN, EXP, and ISO. CCD-TEMP will be ignored for the purposes of these files, as it is
24
+ # assumed they will be taken under roughly the same conditions as the flats are taken.
25
+ #
26
+ # If the files are normal dark files, they will be organized by ISO, EXPOSURE, BIN, CCD-TEMP, and MONTH.
27
+ # With this, you can run WBPP with just bias and darks using the grouping keywords CCD-TEMP, ISO, EXP,
28
+ # and MONTH (optional).
29
+ def organize_darks
30
+ Organizer.new(
31
+ path: path,
32
+ type: Astrophoto::DARK,
33
+ prompt: prompt,
34
+ ).organize(dry_run: is_dry_run?)
35
+ end
36
+
37
+ def organize_biases
38
+ Organizer.new(
39
+ path: path,
40
+ type: Astrophoto::BIAS,
41
+ prompt: prompt,
42
+ ).organize(dry_run: is_dry_run?)
43
+ end
44
+
45
+ # Organizes flat files by FLATSET, ISO, BIN, EXP (EXPOSURE), TELESCOPE, and FILTER. To change these
46
+ # properties, update Astrophoto#target_dir for the FLAT type. The TELESCOPE and FILTER keywords are
47
+ # for matching LIGHTS which will have the same keywords set when organized using this script.
48
+ #
49
+ # You can run WBPP with just your biases, flat darks, and flats using the grouping keywords
50
+ # FLATSET, BIN, EXP, and ISO. CCD-TEMP will be ignored for the purposes of these files, as it is
51
+ # assumed they will be taken under roughly the same conditions as the flat darks are taken.
52
+ #
53
+ # After running WBPP, you should delete the `EXP` keyword from the master flat file name (if present)
54
+ # before using that master flat in a WBPP integration run, since exposure time should not be considered
55
+ # when grouping flats to lights.
56
+ def organize_flats
57
+ Organizer.new(
58
+ path: path,
59
+ type: Astrophoto::FLAT,
60
+ prompt: prompt,
61
+ ).organize(dry_run: is_dry_run?)
62
+ end
63
+
64
+ # Organizes light files by FLATSET, ISO, BIN, EXP (EXPOSURE), TELESCOPE, and FILTER. To change these
65
+ # properties, update Astrophoto#target_dir for the LIGHT type. The TELESCOPE and FILTER keywords are
66
+ # for matching LIGHTS which will have the same keywords set when organized using this script.
67
+ #
68
+ # CCD-TEMP is included in the group naming using a rounded value to facilitate consistent keyword matching
69
+ # in PixInsight WBPP, ensuring frames captured within a temperature range are grouped together.
70
+ #
71
+ # You can run WBPP with just your master biases, master darks, and master flats using the grouping
72
+ # keywords FLATSET, BIN, EXP, CCD-TEMP, and ISO.
73
+ #
74
+ # If you are running WBPP on multiple targets using this data, e.g. for a mosaic, you should make sure
75
+ # to use LIGHT as a post-processing keyword and register files using `auto by LIGHT`.
76
+ def organize_lights
77
+ Organizer.new(
78
+ path: path,
79
+ type: Astrophoto::LIGHT,
80
+ prompt: prompt,
81
+ ).organize(dry_run: is_dry_run?)
82
+ end
83
+
84
+ # Checks for empty directories. Run this option after performing a move of previously
85
+ # organized data.
86
+ def remove_empty_directories
87
+ Utils::EmptyDirectoryCleaner.new(path).cleanup(dry_run: is_dry_run?)
88
+ end
89
+
90
+ # Removes all the jpg thumbnails under this directory.
91
+ def remove_jpg_thumbnails
92
+ Utils::ThumbnailCleaner.new(path).cleanup(dry_run: is_dry_run?)
93
+ end
94
+
95
+ # Renames CR2 Raw files to match the same name pattern as ASIAir does based on EXIF data.
96
+ def rename_from_exif
97
+ renamer = Utils::ExifRenamer.new(path)
98
+ type = prompt.enum_select('What is the file type?', Astrophoto::TYPES)
99
+ target = prompt.ask('What is the target name?') if type == Astrophoto::LIGHT
100
+
101
+ renamer.rename(type: type, target: target, dry_run: is_dry_run?)
102
+ end
103
+
104
+ def is_dry_run? # rubocop:disable Naming/PredicatePrefix
105
+ dry_run.nil? ? prompt.yes?('Is this a dry run?', default: 'y') : dry_run
106
+ end
107
+
108
+ def rename_to_img
109
+ renamer = Utils::ExifRenamer.new(path)
110
+ renamer.revert(dry_run: is_dry_run?)
111
+ end
112
+
113
+ # Prompts the user to choose which organizing task to run. This is the main entry point of
114
+ # this script.
115
+ def organize
116
+ loop do
117
+ message = 'What are we organizing?'
118
+ message += ' (Dry Run)' if dry_run
119
+
120
+ choice = prompt.enum_select message, per_page: 8 do |menu|
121
+ menu.choice 'Darks', :darks
122
+ menu.choice 'Flats', :flats
123
+ menu.choice 'Lights', :lights
124
+ menu.choice 'Biases', :biases
125
+ menu.choice 'Remove empty directories', :empty_dirs
126
+ menu.choice 'Remove jpg thumbnails', :thumbnails
127
+ menu.choice 'Rename files from EXIF data', :rename
128
+ menu.choice 'Quit', :quit
129
+ end
130
+
131
+ case choice
132
+ when :darks then organize_darks
133
+ when :flats then organize_flats
134
+ when :lights then organize_lights
135
+ when :biases then organize_biases
136
+ when :empty_dirs then remove_empty_directories
137
+ when :thumbnails then remove_jpg_thumbnails
138
+ when :rename then rename_from_exif
139
+ when :quit then break
140
+ end
141
+ end
142
+ end
143
+
144
+ def self.run
145
+ organizer = FitsOrganizer.new
146
+ organizer.organize
147
+ end
148
+
149
+ private
150
+
151
+ # TODO: Add menu to select for barlow/flatteners
152
+ def select_accessories; end
153
+ end
154
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ # Mixin to provide logging capabilities to classes in the AstroSubframeOrganizer module.
5
+ module Logging
6
+ def logger
7
+ AstroSubframeOrganizer.logger
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ # Main class responsible for organizing astrophotography subframes based on metadata and user input.
5
+ class Organizer
6
+ include Logging
7
+
8
+ attr_reader :file_sets, :type, :path, :equipment_selector
9
+ protected attr_accessor :prompt
10
+
11
+ def initialize(type:, path: Dir.pwd, prompt: AstroSubframeOrganizer.prompt, equipment_selector: nil)
12
+ @prompt = prompt
13
+ @path = path
14
+ @type = type
15
+ @file_sets = file_sets_for(type)
16
+ @equipment_selector = equipment_selector || EquipmentSelector.new(prompt)
17
+ end
18
+
19
+ def fits_files # rubocop:disable Metrics/AbcSize
20
+ exts = (Config.fits_extensions + Config.raw_extensions).flat_map { |e| [e.downcase, e.upcase] }
21
+ all = Dir.glob(exts.map { |e| "**/*#{e}" }, base: path)
22
+ processable = all.filter { |f| !File.basename(f).match?(Utils::ExifRenamer::RAW_NAME_PATTERN) }
23
+ .uniq
24
+ .map { |relative| File.join(path, relative) }
25
+
26
+ if processable.size != all.size
27
+ unprocessable_raw_files_warning = 'Unprocessed raw images detected, but will be ignored. " +
28
+ "Raw images must be renamed before organizing. Run `astro-subframe-organizer raw rename_from_exif`, " +
29
+ "then try again.'
30
+ logger.warn(unprocessable_raw_files_warning)
31
+ end
32
+
33
+ processable.map { |f| Astrophoto.new(f) }
34
+ end
35
+
36
+ def organize(dry_run: false) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
37
+ logger.info "Preparing to move #{file_sets.sum { |set| set.files.size }} #{type} files " \
38
+ "from #{file_sets.size} groups..."
39
+ @file_sets.each do |fileset|
40
+ next if fileset.already_moved?
41
+
42
+ if fileset.all_unmoved?
43
+ move = ENV['ASTRO_SUBFRAME_SKIP_CONFIRM'] == 'true' ||
44
+ prompt.yes?(
45
+ <<~MSG,
46
+ Preparing to move #{fileset.size} #{fileset.type} file(s)
47
+ FROM #{relative_to_pwd(fileset.current_dir)}
48
+ TO #{relative_to_pwd(fileset.files.first.target_dir)}
49
+ Continue?
50
+ MSG
51
+ default: 'y',
52
+ )
53
+ next unless move
54
+ end
55
+
56
+ logger.info "For #{type} set #{fileset.files.first.filename}..#{fileset.files.last.filename}:"
57
+
58
+ check_telescope(fileset) if [Astrophoto::FLAT, Astrophoto::LIGHT].include?(type)
59
+ check_filter(fileset) if [Astrophoto::FLAT, Astrophoto::LIGHT].include?(type)
60
+ check_camera(fileset)
61
+
62
+ require 'tty-progressbar'
63
+
64
+ # Initialize the bar with the size of the fileset
65
+ bar = TTY::ProgressBar.new('Moving files [:bar] :current/:total (:percent) :eta', total: fileset.size)
66
+
67
+ fileset.each do |file|
68
+ file.move(dry_run, bar)
69
+ bar.advance(1) # Move the bar forward by 1 for each file
70
+ end
71
+
72
+ logger.info 'Done'
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def file_sets_for(type)
79
+ FileSet.from_files(fits_files, type: type)
80
+ end
81
+
82
+ def check_camera(fileset)
83
+ candidates = fileset.camera_candidates
84
+ camera = if candidates.size > 1
85
+ logger.warn "Multiple cameras detected: #{candidates}"
86
+ equipment_selector.choose_camera
87
+ else
88
+ equipment_selector.choose_camera_or_confirm(detected: candidates.first)
89
+ end
90
+
91
+ fileset.apply_camera!(camera)
92
+ end
93
+
94
+ def check_telescope(fileset)
95
+ candidates = fileset.telescope_candidates
96
+ telescope = if candidates.size > 1
97
+ logger.warn "Multiple telescopes detected: #{candidates}"
98
+ equipment_selector.choose_telescope
99
+ else
100
+ equipment_selector.choose_telescope_or_confirm(detected: candidates.first)
101
+ end
102
+
103
+ fileset.apply_telescope!(telescope)
104
+ end
105
+
106
+ def check_filter(fileset)
107
+ candidates = fileset.filter_candidates
108
+ filter = if candidates.size > 1
109
+ logger.warn "Multiple filters detected: #{candidates}"
110
+ equipment_selector.choose_filter
111
+ else
112
+ equipment_selector.choose_filter_or_confirm(detected: candidates.first)
113
+ end
114
+
115
+ fileset.apply_filter!(filter)
116
+ end
117
+
118
+ def relative_to_pwd(path)
119
+ relative = Pathname.new(path).relative_path_from(Dir.pwd).to_s
120
+ relative.start_with?('..') ? path : relative
121
+ rescue ArgumentError
122
+ path # fallback to absolute path on different drives (Windows)
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ # Factory for creating and using path builders.
5
+ #
6
+ # This class serves as the entry point for building folder paths for astrophotography
7
+ # image files. It uses the Strategy pattern to delegate path construction to specialized
8
+ # builder classes based on the image file type (Dark, Flat, Light, or Bias).
9
+ #
10
+ # The resulting folder paths contain keywords that facilitate automatic file matching in
11
+ # PixInsight's WeightedBatchPreProcessing (WBPP) script during calibration and integration.
12
+ #
13
+ # **Usage:**
14
+ # path = PathBuilder.build_for(photo_metadata) # => "Dark_ISO_100_EXP_30.0s_..."
15
+ # full_path = PathBuilder.target_path_for(metadata) # => "Dark_ISO_100_.../filename.fit"
16
+ #
17
+ # See the individual builder classes for details on keyword structure for each file type:
18
+ # - DarkPathBuilder for dark calibration frames
19
+ # - FlatPathBuilder for flat field frames
20
+ # - LightPathBuilder for science (light) frames
21
+ # - BiasPathBuilder for bias (zero-exposure) frames
22
+ #
23
+ # See README.md for details on the WBPP calibration workflow and keyword matching.
24
+ class PathBuilder
25
+ # Builds the folder path for an image based on its type and metadata.
26
+ #
27
+ # @param metadata [Object] Image metadata object (typically an Astrophoto instance)
28
+ # Must respond to: type, dark_flat?, file_format
29
+ # @return [String] The folder path with WBPP-compatible keywords
30
+ # @raise [ArgumentError] If the image type is not supported
31
+ def self.build_for(metadata)
32
+ builder = case metadata.type
33
+ when 'Dark'
34
+ PathBuilders::DarkPathBuilder.new(metadata)
35
+ when 'Flat'
36
+ PathBuilders::FlatPathBuilder.new(metadata)
37
+ when 'Light'
38
+ PathBuilders::LightPathBuilder.new(metadata)
39
+ when 'Bias'
40
+ PathBuilders::BiasPathBuilder.new(metadata)
41
+ else
42
+ raise ArgumentError, "Unsupported type: #{metadata.type}"
43
+ end
44
+ builder.build
45
+ end
46
+
47
+ # Builds the full target path (folder + filename) for an image.
48
+ #
49
+ # @param metadata [Object] Image metadata object with filename and type
50
+ # @return [String] The full path where the file will be moved
51
+ def self.target_path_for(metadata)
52
+ File.join(build_for(metadata), metadata.filename)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ module PathBuilders
5
+ # Base class for all path builders.
6
+ #
7
+ # Path builders construct folder paths based on image metadata, using keywords that
8
+ # facilitate organization for PixInsight's WeightedBatchPreProcessing (WBPP) script.
9
+ # WBPP uses these keywords to automatically match and group images for calibration and
10
+ # integration tasks.
11
+ #
12
+ # Subclasses should implement a `build` method that returns the folder path as a string
13
+ # with keyword-value pairs separated by underscores (e.g., "Dark_ISO_100_EXP_30.0s_...").
14
+ #
15
+ # See README.md for details on how WBPP uses these keywords in the calibration workflow.
16
+ class BasePathBuilder
17
+ def initialize(metadata)
18
+ @metadata = metadata
19
+ end
20
+
21
+ protected
22
+
23
+ # @return [String, nil] "ISO_<value>" if iso is present, "GAIN_<value>" if gain is present
24
+ def iso_or_gain
25
+ if @metadata.iso
26
+ "ISO_#{@metadata.iso}"
27
+ elsif @metadata.gain
28
+ "GAIN_#{@metadata.gain}"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroSubframeOrganizer
4
+ module PathBuilders
5
+ # Builds folder paths for bias (zero-exposure) calibration frames.
6
+ #
7
+ # Bias frames are organized by the following keywords, which allow WBPP to
8
+ # automatically match biases during calibration of darks, flats, and lights:
9
+ #
10
+ # Bias_ISO_<value>_EXP_<value>_Bin_<value>_CAMERA_<model>_MONTH_<YYYY-MM>
11
+ #
12
+ # **Keyword meanings:**
13
+ # - ISO: Camera ISO setting
14
+ # - EXP: Exposure time (typically 0.0s for bias frames)
15
+ # - Bin: Binning mode
16
+ # - CAMERA: Camera model
17
+ # - MONTH: Year-Month in YYYY-MM format (used for seasonal grouping)
18
+ #
19
+ # Bias frames are primarily used as the baseline calibration for darks, flats, and lights
20
+ # to remove electronic noise. WBPP uses these keywords to match an appropriate master bias
21
+ # based on the ISO, exposure, binning, and season of acquisition.
22
+ #
23
+ # See README.md for details on the WBPP calibration workflow.
24
+ class BiasPathBuilder < BasePathBuilder
25
+ # @return [String] The folder path for this bias frame
26
+ def build
27
+ [
28
+ 'Bias',
29
+ iso_or_gain,
30
+ "EXP_#{@metadata.exposure}",
31
+ "Bin_#{@metadata.bin}",
32
+ "CAMERA_#{@metadata.camera || '????'}",
33
+ "MONTH_#{@metadata.month}",
34
+ ].compact.join('_')
35
+ end
36
+ end
37
+ end
38
+ end