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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +737 -0
- data/exe/astro-subframe-organizer +8 -0
- data/lib/astro_subframe_organizer/astrophoto.rb +122 -0
- data/lib/astro_subframe_organizer/commands/cleanup/empty_directories.rb +27 -0
- data/lib/astro_subframe_organizer/commands/cleanup/thumbnails.rb +38 -0
- data/lib/astro_subframe_organizer/commands/cleanup/unorganize.rb +25 -0
- data/lib/astro_subframe_organizer/commands/equipment_options.rb +30 -0
- data/lib/astro_subframe_organizer/commands/init.rb +54 -0
- data/lib/astro_subframe_organizer/commands/inspect.rb +101 -0
- data/lib/astro_subframe_organizer/commands/organize/base.rb +43 -0
- data/lib/astro_subframe_organizer/commands/organize/bias.rb +13 -0
- data/lib/astro_subframe_organizer/commands/organize/darks.rb +12 -0
- data/lib/astro_subframe_organizer/commands/organize/flats.rb +14 -0
- data/lib/astro_subframe_organizer/commands/organize/lights.rb +14 -0
- data/lib/astro_subframe_organizer/commands/raw/rename_from_exif.rb +45 -0
- data/lib/astro_subframe_organizer/commands/raw/revert_name.rb +28 -0
- data/lib/astro_subframe_organizer/commands/run.rb +28 -0
- data/lib/astro_subframe_organizer/commands/shared_options.rb +29 -0
- data/lib/astro_subframe_organizer/commands/version.rb +15 -0
- data/lib/astro_subframe_organizer/commands.rb +64 -0
- data/lib/astro_subframe_organizer/config.rb +140 -0
- data/lib/astro_subframe_organizer/equipment/camera.rb +14 -0
- data/lib/astro_subframe_organizer/equipment/filter.rb +14 -0
- data/lib/astro_subframe_organizer/equipment/telescope.rb +14 -0
- data/lib/astro_subframe_organizer/equipment_selector.rb +103 -0
- data/lib/astro_subframe_organizer/file_metadata.rb +135 -0
- data/lib/astro_subframe_organizer/file_set.rb +107 -0
- data/lib/astro_subframe_organizer/filename_parser.rb +106 -0
- data/lib/astro_subframe_organizer/filename_parsers/cr2_filename_parser.rb +67 -0
- data/lib/astro_subframe_organizer/filename_parsers/fits_filename_parser.rb +67 -0
- data/lib/astro_subframe_organizer/filename_parsers/fits_header_parser.rb +120 -0
- data/lib/astro_subframe_organizer/fits_organizer.rb +154 -0
- data/lib/astro_subframe_organizer/logging.rb +10 -0
- data/lib/astro_subframe_organizer/organizer.rb +125 -0
- data/lib/astro_subframe_organizer/path_builder.rb +55 -0
- data/lib/astro_subframe_organizer/path_builders/base_path_builder.rb +33 -0
- data/lib/astro_subframe_organizer/path_builders/bias_path_builder.rb +38 -0
- data/lib/astro_subframe_organizer/path_builders/dark_path_builder.rb +61 -0
- data/lib/astro_subframe_organizer/path_builders/flat_path_builder.rb +48 -0
- data/lib/astro_subframe_organizer/path_builders/light_path_builder.rb +53 -0
- data/lib/astro_subframe_organizer/utils/empty_directory_cleaner.rb +36 -0
- data/lib/astro_subframe_organizer/utils/exif_renamer.rb +132 -0
- data/lib/astro_subframe_organizer/utils/exposure_format.rb +25 -0
- data/lib/astro_subframe_organizer/utils/file_utils.rb +10 -0
- data/lib/astro_subframe_organizer/utils/fits_stripper.rb +115 -0
- data/lib/astro_subframe_organizer/utils/raw_stripper.rb +47 -0
- data/lib/astro_subframe_organizer/utils/thumbnail_cleaner.rb +26 -0
- data/lib/astro_subframe_organizer/utils/unorganizer.rb +54 -0
- data/lib/astro_subframe_organizer/version.rb +5 -0
- data/lib/astro_subframe_organizer.rb +103 -0
- metadata +182 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dry/cli'
|
|
4
|
+
|
|
5
|
+
require 'astro_subframe_organizer/commands/shared_options'
|
|
6
|
+
require 'astro_subframe_organizer/commands/equipment_options'
|
|
7
|
+
require 'astro_subframe_organizer/commands/init'
|
|
8
|
+
require 'astro_subframe_organizer/commands/run'
|
|
9
|
+
require 'astro_subframe_organizer/commands/version'
|
|
10
|
+
require 'astro_subframe_organizer/commands/inspect'
|
|
11
|
+
require 'astro_subframe_organizer/commands/organize/base'
|
|
12
|
+
require 'astro_subframe_organizer/commands/organize/lights'
|
|
13
|
+
require 'astro_subframe_organizer/commands/organize/darks'
|
|
14
|
+
require 'astro_subframe_organizer/commands/organize/flats'
|
|
15
|
+
require 'astro_subframe_organizer/commands/organize/bias'
|
|
16
|
+
require 'astro_subframe_organizer/commands/raw/rename_from_exif'
|
|
17
|
+
require 'astro_subframe_organizer/commands/raw/revert_name'
|
|
18
|
+
require 'astro_subframe_organizer/commands/cleanup/thumbnails'
|
|
19
|
+
require 'astro_subframe_organizer/commands/cleanup/empty_directories'
|
|
20
|
+
require 'astro_subframe_organizer/commands/cleanup/unorganize'
|
|
21
|
+
|
|
22
|
+
module AstroSubframeOrganizer
|
|
23
|
+
# The main command registry for the AstroSubframeOrganizer CLI, using Dry::CLI to define and
|
|
24
|
+
# organize commands and their aliases. This module registers all the available commands for
|
|
25
|
+
# the CLI, including:
|
|
26
|
+
#
|
|
27
|
+
# - Version display
|
|
28
|
+
# - Initialization of configuration
|
|
29
|
+
# - Running the organizer in interactive mode
|
|
30
|
+
# - Inspecting file metadata
|
|
31
|
+
# - Organizing different types of subframes (lights, darks, flats, biases)
|
|
32
|
+
# - Renaming RAW files based on EXIF data
|
|
33
|
+
# - Cleaning up thumbnails and empty directories
|
|
34
|
+
# - Reverting organization changes
|
|
35
|
+
module Commands
|
|
36
|
+
extend Dry::CLI::Registry
|
|
37
|
+
|
|
38
|
+
register 'version', Version, aliases: ['v', '-v', '--version']
|
|
39
|
+
register 'init', Init, aliases: ['--init']
|
|
40
|
+
register 'run', Run, aliases: %w[-i --interactive]
|
|
41
|
+
register 'inspect', Inspect, aliases: %w[metadata view headers]
|
|
42
|
+
|
|
43
|
+
register 'lights', Organize::Lights, aliases: %w[light --light --lights]
|
|
44
|
+
register 'darks', Organize::Darks, aliases: %w[dark --dark --darks]
|
|
45
|
+
register 'flats', Organize::Flats, aliases: %w[flat --flat --flats]
|
|
46
|
+
register 'biases', Organize::Bias, aliases: %w[bias --bias --biases]
|
|
47
|
+
register 'unorganize', Cleanup::Unorganize, aliases: %w[reset revert undo]
|
|
48
|
+
|
|
49
|
+
register 'cleanup', aliases: %w[clean] do |prefix|
|
|
50
|
+
prefix.register 'thumbnails',
|
|
51
|
+
Cleanup::Thumbnails,
|
|
52
|
+
aliases: %w[thn thm th thumbs]
|
|
53
|
+
|
|
54
|
+
prefix.register 'empty-directories',
|
|
55
|
+
Cleanup::EmptyDirectories,
|
|
56
|
+
aliases: %w[empty empties empty-folders]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
register 'raw' do |prefix|
|
|
60
|
+
prefix.register 'rename', Raw::RenameFromExif, aliases: %w[autoname]
|
|
61
|
+
prefix.register 'revert', Raw::RevertToRaw, aliases: %w[undo reset]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
# Ensure standard streams are unbuffered for reliable output capture in CI (especially Windows)
|
|
6
|
+
$stdout.sync = true
|
|
7
|
+
$stderr.sync = true
|
|
8
|
+
|
|
9
|
+
module AstroSubframeOrganizer
|
|
10
|
+
# Configuration management for user-customizable options
|
|
11
|
+
class Config
|
|
12
|
+
DEFAULT_CONFIG = {
|
|
13
|
+
'telescopes' => %w[
|
|
14
|
+
RedCat51
|
|
15
|
+
ZhumellZ130
|
|
16
|
+
AperturaAD8
|
|
17
|
+
MeadeDS90
|
|
18
|
+
CanonEFS1855
|
|
19
|
+
],
|
|
20
|
+
'filters' => %w[
|
|
21
|
+
BaaderMoon
|
|
22
|
+
NBZ
|
|
23
|
+
NoFilter
|
|
24
|
+
],
|
|
25
|
+
'cameras' => [
|
|
26
|
+
'T7',
|
|
27
|
+
'183MC',
|
|
28
|
+
'ZWO ASI183MC Pro',
|
|
29
|
+
'Canon EOS 1500D',
|
|
30
|
+
],
|
|
31
|
+
'temperature_tolerance' => 5.0,
|
|
32
|
+
'fits_extensions' => %w[.fit .fits .fts],
|
|
33
|
+
'raw_extensions' => %w[.cr2 .cr3 .nef .arw .orf .raf .dng],
|
|
34
|
+
'exif_tag_mappings' => {
|
|
35
|
+
'temperature' => %i[camera_temperature sensor_temperature ambient_temperature],
|
|
36
|
+
'iso' => %i[iso base_iso],
|
|
37
|
+
'exposure' => %i[exposure_time],
|
|
38
|
+
'model' => %i[model],
|
|
39
|
+
'timestamp' => %i[date_time_original],
|
|
40
|
+
},
|
|
41
|
+
'fits_header_mappings' => {
|
|
42
|
+
'temperature' => %w[CCD-TEMP SET-TEMP TEMP],
|
|
43
|
+
'gain' => %w[GAIN GAINVAL],
|
|
44
|
+
'exposure' => %w[EXPOSURE EXPTIME],
|
|
45
|
+
'filter' => %w[FILTER FILTERNAME],
|
|
46
|
+
'telescope' => %w[TELESCOP],
|
|
47
|
+
'target' => %w[OBJECT TARGET],
|
|
48
|
+
'camera' => %w[INSTRUME],
|
|
49
|
+
'binning' => %w[XBINNING CCDXBIN BINNING],
|
|
50
|
+
'type' => %w[IMAGETYP FRAME],
|
|
51
|
+
'date_obs' => %w[DATE-OBS DATE],
|
|
52
|
+
'rotation' => %w[ROTATANG ANGLE POSANGLE ROTATOR ROTAT OBJCTROT CCDROTSA],
|
|
53
|
+
'iso' => %w[ISO],
|
|
54
|
+
},
|
|
55
|
+
'telescope_ignore_patterns' => [
|
|
56
|
+
'Mount',
|
|
57
|
+
'EQMod',
|
|
58
|
+
'AM5',
|
|
59
|
+
'AM3',
|
|
60
|
+
'RST-135',
|
|
61
|
+
'Star Adventurer',
|
|
62
|
+
],
|
|
63
|
+
}.freeze
|
|
64
|
+
|
|
65
|
+
def self.custom_config_file
|
|
66
|
+
ENV.fetch('ASTRO_SUBFRAME_ORGANIZER_CONFIG', nil)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Returns the expanded path to the configuration file.
|
|
70
|
+
def self.config_file
|
|
71
|
+
path = custom_config_file || '~/astro-subframe-organizer-config.yml'
|
|
72
|
+
File.expand_path(path)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.load
|
|
76
|
+
@load ||=
|
|
77
|
+
if File.exist?(config_file)
|
|
78
|
+
# Normalize path separators for consistent logging/testing across platforms
|
|
79
|
+
AstroSubframeOrganizer.logger.info "Using config file at #{config_file.tr('\\', '/')}"
|
|
80
|
+
DEFAULT_CONFIG.merge(YAML.safe_load_file(config_file, permitted_classes: [Symbol, DateTime]))
|
|
81
|
+
elsif custom_config_file
|
|
82
|
+
AstroSubframeOrganizer.logger.error("Unable to find #{config_file.tr('\\', '/')}. Check path and try again.")
|
|
83
|
+
exit(1)
|
|
84
|
+
else
|
|
85
|
+
AstroSubframeOrganizer.logger.info "Using config file at #{config_file.tr('\\', '/')}"
|
|
86
|
+
DEFAULT_CONFIG
|
|
87
|
+
end
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
AstroSubframeOrganizer.logger.error("Failed to parse #{config_file}: #{e}")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.all_telescopes
|
|
93
|
+
load['telescopes']
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.all_filters
|
|
97
|
+
load['filters']
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.all_cameras
|
|
101
|
+
load['cameras']
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.temperature_tolerance
|
|
105
|
+
load['temperature_tolerance']&.to_f || 5.0
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.fits_extensions
|
|
109
|
+
load['fits_extensions'] || DEFAULT_CONFIG['fits_extensions']
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.raw_extensions
|
|
113
|
+
load['raw_extensions'] || DEFAULT_CONFIG['raw_extensions']
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def self.exif_tag_mappings
|
|
117
|
+
load['exif_tag_mappings'] || DEFAULT_CONFIG['exif_tag_mappings']
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.fits_header_mappings
|
|
121
|
+
load['fits_header_mappings'] || DEFAULT_CONFIG['fits_header_mappings']
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.telescope_ignore_patterns
|
|
125
|
+
patterns = load['telescope_ignore_patterns'] || DEFAULT_CONFIG['telescope_ignore_patterns']
|
|
126
|
+
patterns.map { |p| Regexp.new(Regexp.escape(p), Regexp::IGNORECASE) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.create_default_config
|
|
130
|
+
# Only include basic equipment and settings in the generated template.
|
|
131
|
+
# Advanced mappings and ignore patterns are handled via application defaults
|
|
132
|
+
# unless explicitly overridden by the user.
|
|
133
|
+
template = DEFAULT_CONFIG.slice(
|
|
134
|
+
'telescopes', 'filters', 'cameras', 'temperature_tolerance'
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
File.write(config_file, template.to_yaml)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AstroSubframeOrganizer
|
|
4
|
+
module Equipment
|
|
5
|
+
# Add your filters to `~/astro-subframe-organizer-config.yml`. If there is more
|
|
6
|
+
# than one, and no camera is automatically detected, you will be prompted
|
|
7
|
+
# to choose one of them when organizing subframes.
|
|
8
|
+
class Camera
|
|
9
|
+
def self.all
|
|
10
|
+
Config.all_cameras
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AstroSubframeOrganizer
|
|
4
|
+
module Equipment
|
|
5
|
+
# Add your filters to `~/astro-subframe-organizer-config.yml`. If there is more
|
|
6
|
+
# than one, you will be prompted to choose one of them when organizing flats
|
|
7
|
+
# and lights.
|
|
8
|
+
class Filter
|
|
9
|
+
def self.all
|
|
10
|
+
Config.all_filters
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AstroSubframeOrganizer
|
|
4
|
+
module Equipment
|
|
5
|
+
# Add your telescopes to `~/astro-subframe-organizer-config.yml`. If there is more
|
|
6
|
+
# than one, you will be prompted to choose one of them when organizing flats
|
|
7
|
+
# and lights.
|
|
8
|
+
class Telescope
|
|
9
|
+
def self.all
|
|
10
|
+
Config.all_telescopes
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AstroSubframeOrganizer
|
|
4
|
+
# Handles interactive selection of equipment based on configuration and FITS header detection.
|
|
5
|
+
class EquipmentSelector
|
|
6
|
+
include Equipment
|
|
7
|
+
include Logging
|
|
8
|
+
|
|
9
|
+
attr_accessor :telescope, :camera, :filter
|
|
10
|
+
|
|
11
|
+
def initialize(
|
|
12
|
+
prompt = AstroSubframeOrganizer.prompt,
|
|
13
|
+
telescopes: Telescope.all,
|
|
14
|
+
cameras: Camera.all,
|
|
15
|
+
filters: Filter.all
|
|
16
|
+
)
|
|
17
|
+
@telescopes = telescopes
|
|
18
|
+
@cameras = cameras
|
|
19
|
+
@filters = filters
|
|
20
|
+
@prompt = prompt
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def choose_telescope(index = nil)
|
|
24
|
+
generic_choose(telescope, @telescopes, 'telescope', index, 'What telescope is this set for?')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def choose_telescope_or_confirm(detected:)
|
|
28
|
+
if detected && Config.telescope_ignore_patterns.any? { |p| detected.match?(p) }
|
|
29
|
+
logger.info "Ignoring detected mount name: '#{detected}'"
|
|
30
|
+
detected = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if detected
|
|
34
|
+
suggestion = "If '#{detected}' is a mount name, consider adding it to 'telescope_ignore_patterns' " \
|
|
35
|
+
'in your config.'
|
|
36
|
+
end
|
|
37
|
+
generic_choose_or_confirm(telescope, @telescopes, 'telescope', 'TELESCOP', detected, suggestion: suggestion)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def choose_camera(index = nil)
|
|
41
|
+
generic_choose(camera, @cameras, 'camera', index)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def choose_camera_or_confirm(detected:)
|
|
45
|
+
generic_choose_or_confirm(camera, @cameras, 'camera', 'INSTRUME', detected)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def choose_filter(index = nil)
|
|
49
|
+
generic_choose(filter, @filters, 'filter', index)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def choose_filter_or_confirm(detected:)
|
|
53
|
+
generic_choose_or_confirm(filter, @filters, 'filter', 'FILTER', detected)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def generic_choose(current_value, collection, label, index = nil, custom_prompt = nil)
|
|
59
|
+
return current_value if current_value
|
|
60
|
+
|
|
61
|
+
if index && collection[index]
|
|
62
|
+
collection[index]
|
|
63
|
+
elsif collection.one?
|
|
64
|
+
collection.first
|
|
65
|
+
else
|
|
66
|
+
prompt_text = custom_prompt || "What #{label} is used with this set?"
|
|
67
|
+
choose(prompt_text, collection)
|
|
68
|
+
end.tap { |chosen| logger.info("Selected #{label.capitalize}: #{chosen}") }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def generic_choose_or_confirm(
|
|
72
|
+
current_value,
|
|
73
|
+
collection,
|
|
74
|
+
label,
|
|
75
|
+
header_name,
|
|
76
|
+
detected,
|
|
77
|
+
suggestion: nil
|
|
78
|
+
)
|
|
79
|
+
if current_value
|
|
80
|
+
logger.warn "Using #{label} #{current_value}, but detected #{detected}" if detected && current_value != detected
|
|
81
|
+
return current_value
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if detected && collection.include?(detected)
|
|
85
|
+
detected
|
|
86
|
+
elsif detected
|
|
87
|
+
logger.warn "#{header_name} header '#{detected}' is not in the configured #{label} list."
|
|
88
|
+
logger.info suggestion if suggestion
|
|
89
|
+
choose(
|
|
90
|
+
"#{header_name} is '#{detected}' — select the actual #{label} or confirm:",
|
|
91
|
+
[detected] + collection,
|
|
92
|
+
).tap { |chosen| logger.info("Selected #{label.capitalize}: #{chosen}") }
|
|
93
|
+
else
|
|
94
|
+
logger.warn "#{label.capitalize} auto-detect failed."
|
|
95
|
+
send("choose_#{label}")
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def choose(prompt, options)
|
|
100
|
+
@prompt.enum_select prompt, options
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AstroSubframeOrganizer
|
|
4
|
+
# Value object representing parsed metadata from an astrophotography file
|
|
5
|
+
class FileMetadata
|
|
6
|
+
attr_accessor :path, :type, :target, :camera, :telescope, :filter, :dark_flat
|
|
7
|
+
attr_reader :filename,
|
|
8
|
+
:file_format,
|
|
9
|
+
:exposure,
|
|
10
|
+
:bin,
|
|
11
|
+
:gain,
|
|
12
|
+
:iso,
|
|
13
|
+
:created_at,
|
|
14
|
+
:ccd_temp,
|
|
15
|
+
:image_index,
|
|
16
|
+
:mosaic_pane,
|
|
17
|
+
:rotation
|
|
18
|
+
|
|
19
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
20
|
+
type:, path:, filename:, file_format:, exposure: nil, bin: nil, camera: nil,
|
|
21
|
+
iso: nil, gain: nil, created_at: nil, ccd_temp: nil, image_index: nil,
|
|
22
|
+
target: nil, telescope: nil, filter: nil, dark_flat: false, mosaic_pane: nil,
|
|
23
|
+
rotation: nil
|
|
24
|
+
)
|
|
25
|
+
@type = type
|
|
26
|
+
@path = path
|
|
27
|
+
@filename = filename
|
|
28
|
+
@file_format = file_format
|
|
29
|
+
@exposure = exposure
|
|
30
|
+
@bin = bin
|
|
31
|
+
@camera = camera
|
|
32
|
+
@iso = iso
|
|
33
|
+
@gain = gain
|
|
34
|
+
@created_at = created_at
|
|
35
|
+
@ccd_temp = ccd_temp
|
|
36
|
+
@image_index = image_index
|
|
37
|
+
@target = target
|
|
38
|
+
@telescope = telescope
|
|
39
|
+
@filter = filter
|
|
40
|
+
@dark_flat = dark_flat
|
|
41
|
+
@mosaic_pane = mosaic_pane
|
|
42
|
+
@rotation = rotation
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Factory method to create FileMetadata from parsed parser result
|
|
46
|
+
def self.from_parsed_data(parsed_data)
|
|
47
|
+
return parsed_data if parsed_data.instance_of? FileMetadata
|
|
48
|
+
|
|
49
|
+
new(
|
|
50
|
+
type: parsed_data[:type],
|
|
51
|
+
path: parsed_data[:path],
|
|
52
|
+
filename: parsed_data[:filename],
|
|
53
|
+
file_format: parsed_data[:file_format],
|
|
54
|
+
target: parsed_data[:target],
|
|
55
|
+
mosaic_pane: parsed_data[:mosaic_pane],
|
|
56
|
+
exposure: parsed_data[:exposure],
|
|
57
|
+
bin: parsed_data[:bin],
|
|
58
|
+
camera: parsed_data[:camera],
|
|
59
|
+
iso: parsed_data[:iso],
|
|
60
|
+
gain: parsed_data[:gain],
|
|
61
|
+
created_at: parsed_data[:created_at],
|
|
62
|
+
ccd_temp: parsed_data[:ccd_temp],
|
|
63
|
+
image_index: parsed_data[:image_index],
|
|
64
|
+
telescope: parsed_data[:telescope],
|
|
65
|
+
filter: parsed_data[:filter],
|
|
66
|
+
dark_flat: parsed_data[:dark_flat] || false,
|
|
67
|
+
rotation: parsed_data[:rotation],
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def dark_flat?
|
|
72
|
+
dark_flat
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# True if the dark is likely a dark flat and hasn't already been organized as dark flat
|
|
76
|
+
def maybe_flat_dark?
|
|
77
|
+
exp_val = exposure.to_f
|
|
78
|
+
exp_units = exposure.gsub(exp_val.to_s, '')
|
|
79
|
+
exp_in_seconds = case exp_units
|
|
80
|
+
when 's'
|
|
81
|
+
exp_val
|
|
82
|
+
when 'ms'
|
|
83
|
+
exp_val / 1000.0
|
|
84
|
+
when 'us'
|
|
85
|
+
exp_val / 1_000_000.0
|
|
86
|
+
end
|
|
87
|
+
type == 'Dark' && exp_in_seconds <= 10.0 && !dark_flat?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The date formatted like '20220508'. If pictures taken in latter half of day,
|
|
91
|
+
# assume flatset will be generated the next day
|
|
92
|
+
def flatset_id
|
|
93
|
+
if type == 'Light' && created_at.hour >= 12
|
|
94
|
+
created_at.next_day.strftime('%Y%m%d')
|
|
95
|
+
else
|
|
96
|
+
created_at.strftime('%Y%m%d')
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The Year-Month in which image was taken. Useful for grouping darks by season
|
|
101
|
+
def month
|
|
102
|
+
created_at.strftime('%Y-%m')
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Current directory of the file
|
|
106
|
+
def current_dir
|
|
107
|
+
File.dirname(path)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# True if the path is already at target destination
|
|
111
|
+
def already_moved?(target_path)
|
|
112
|
+
p1 = File.expand_path(path)
|
|
113
|
+
p2 = File.expand_path(target_path)
|
|
114
|
+
|
|
115
|
+
# On Windows, path comparison should be case-insensitive
|
|
116
|
+
Gem.win_platform? ? p1.casecmp?(p2) : p1 == p2
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def rounded_ccd_temp(tolerance: Config.temperature_tolerance)
|
|
120
|
+
return nil if ccd_temp.nil?
|
|
121
|
+
|
|
122
|
+
temp_value = ccd_temp.to_f
|
|
123
|
+
rounded = (temp_value / tolerance).round * tolerance
|
|
124
|
+
# Master darks output by PixInsight have CCD-TEMP formatted `CCD-TEMP_-10.` for some reason.
|
|
125
|
+
format('%.1fC', rounded).gsub('0C', '')
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def normalized_rotation
|
|
129
|
+
return nil if rotation.nil?
|
|
130
|
+
|
|
131
|
+
angle = rotation.to_i % 180
|
|
132
|
+
format('%d', angle)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AstroSubframeOrganizer
|
|
4
|
+
# Value object representing a set of files that belong together based on type and metadata.
|
|
5
|
+
class FileSet
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
attr_reader :files
|
|
9
|
+
|
|
10
|
+
def initialize(files)
|
|
11
|
+
@files = files
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Factory method to create list of FileSet instances from a list of FileMetadata objects,
|
|
15
|
+
# grouping them by type and relevant metadata.
|
|
16
|
+
def self.from_files(files, type:)
|
|
17
|
+
files.select { |file| file.type == type }
|
|
18
|
+
# Normalize path for sorting: unified separators and consistent case (for Windows)
|
|
19
|
+
.sort_by { |file| [File.dirname(file.path).tr('\\', '/').downcase, file.filename.downcase] }
|
|
20
|
+
.slice_when { |a, b| new_group?(a, b) }
|
|
21
|
+
.map { |group| new(group) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.new_group?(first, second)
|
|
25
|
+
first.image_index.to_i > second.image_index.to_i ||
|
|
26
|
+
first.camera != second.camera ||
|
|
27
|
+
first.telescope != second.telescope ||
|
|
28
|
+
first.filter != second.filter
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def name
|
|
32
|
+
"#{type} set #{files.first.filename}..#{files.last.filename}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def each(&)
|
|
36
|
+
@files.each(&)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def size
|
|
40
|
+
@files.size
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def type
|
|
44
|
+
files.first.type
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def current_dir
|
|
48
|
+
files.first.current_dir
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def already_moved?
|
|
52
|
+
files.all?(&:already_moved?)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def all_unmoved?
|
|
56
|
+
files.all? { |file| file.path != file.target_path }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def any_unmoved?
|
|
60
|
+
files.any? { |file| file.path != file.target_path }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def camera_candidates
|
|
64
|
+
files.filter_map(&:camera).uniq
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def camera
|
|
68
|
+
camera_candidates.one? ? camera_candidates.first : nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def apply_camera!(camera)
|
|
72
|
+
files.each { |file| file.camera = camera }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def telescope_candidates
|
|
76
|
+
files.filter_map(&:telescope).uniq
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def telescope
|
|
80
|
+
telescope_candidates.one? ? telescope_candidates.first : nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def apply_telescope!(telescope)
|
|
84
|
+
files.each { |file| file.telescope = telescope }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def filter_candidates
|
|
88
|
+
files.filter_map(&:filter).uniq
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def filter
|
|
92
|
+
filter_candidates.one? ? filter_candidates.first : nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def apply_filter!(filter)
|
|
96
|
+
files.each { |file| file.filter = filter }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def maybe_flat_dark?
|
|
100
|
+
files.all?(&:maybe_flat_dark?)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def mark_dark_flat!
|
|
104
|
+
files.each { |file| file.dark_flat = true }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'forwardable'
|
|
4
|
+
|
|
5
|
+
module AstroSubframeOrganizer
|
|
6
|
+
# Base class for parsing astrophotography filenames.
|
|
7
|
+
#
|
|
8
|
+
# This class uses the Strategy pattern to handle format-specific filename parsing.
|
|
9
|
+
# The factory method `for_file` creates the appropriate parser subclass based on file extension.
|
|
10
|
+
#
|
|
11
|
+
# Subclasses must implement the `parse` method, which returns a hash of metadata keys
|
|
12
|
+
# extracted from the filename.
|
|
13
|
+
#
|
|
14
|
+
# **Usage:**
|
|
15
|
+
# parser = FilenameParser.for_file('/path/to/Light_M42_1.0s_Bin1_T7_ISO100_20220508-120000_-10.0C_0001.fit')
|
|
16
|
+
# metadata_hash = parser.parse
|
|
17
|
+
#
|
|
18
|
+
# See FitsFilenameParser and CR2FilenameParser for format-specific implementations.
|
|
19
|
+
class FilenameParser
|
|
20
|
+
extend Forwardable
|
|
21
|
+
|
|
22
|
+
attr_reader :filename, :path, :result
|
|
23
|
+
|
|
24
|
+
def_delegators :result,
|
|
25
|
+
:type,
|
|
26
|
+
:exposure,
|
|
27
|
+
:bin,
|
|
28
|
+
:camera,
|
|
29
|
+
:gain,
|
|
30
|
+
:iso,
|
|
31
|
+
:created_at,
|
|
32
|
+
:ccd_temp,
|
|
33
|
+
:image_index,
|
|
34
|
+
:telescope,
|
|
35
|
+
:filter,
|
|
36
|
+
:target,
|
|
37
|
+
:dark_flat,
|
|
38
|
+
:mosaic_pane
|
|
39
|
+
|
|
40
|
+
def initialize(path)
|
|
41
|
+
@path = path
|
|
42
|
+
@filename = File.basename(path)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns a hash of parsed metadata.
|
|
46
|
+
#
|
|
47
|
+
# Subclasses must implement this method.
|
|
48
|
+
#
|
|
49
|
+
# @return [FileMetadata] Parsed metadata with attributes like :type, :target, :exposure, etc.
|
|
50
|
+
# @raise [NotImplementedError] If not implemented by subclass
|
|
51
|
+
def parse
|
|
52
|
+
raise NotImplementedError, 'Subclasses must implement #parse'
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Factory method to create the appropriate parser for a file.
|
|
56
|
+
#
|
|
57
|
+
# Determines the correct parser based on file extension (.fit or .cr2).
|
|
58
|
+
# Case-insensitive for file extensions.
|
|
59
|
+
#
|
|
60
|
+
# @param path [String] Full path to the image file
|
|
61
|
+
# @return [FitsFilenameParser, CR2FilenameParser] Appropriate parser instance
|
|
62
|
+
# @raise [ArgumentError] If file format is not supported
|
|
63
|
+
def self.for_file(path, use_headers: true)
|
|
64
|
+
ext = File.extname(path).downcase
|
|
65
|
+
if Config.fits_extensions.include?(ext)
|
|
66
|
+
if use_headers
|
|
67
|
+
FilenameParsers::FitsHeaderParser.new(path)
|
|
68
|
+
else
|
|
69
|
+
FilenameParsers::FitsFilenameParser.new(path)
|
|
70
|
+
end
|
|
71
|
+
elsif Config.raw_extensions.include?(ext)
|
|
72
|
+
FilenameParsers::CR2FilenameParser.new(path)
|
|
73
|
+
else
|
|
74
|
+
raise ArgumentError, "Unsupported file format: #{path}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
protected
|
|
79
|
+
|
|
80
|
+
# Extracts metadata often embedded in the directory structure.
|
|
81
|
+
#
|
|
82
|
+
# @return [Hash] Metadata found in path (telescope, filter, dark_flat)
|
|
83
|
+
def extract_metadata_from_path
|
|
84
|
+
{
|
|
85
|
+
telescope: path.match(%r{TELESCOPE_([^_/\\]+).*})&.captures&.first,
|
|
86
|
+
filter: path.match(%r{FILTER_([^_/\\]+).*})&.captures&.first,
|
|
87
|
+
dark_flat: path.match?(/DarkFlat/i),
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Removes file extension from filename.
|
|
92
|
+
#
|
|
93
|
+
# @return [String] Filename without extension
|
|
94
|
+
def extract_base_name
|
|
95
|
+
File.basename(@filename, '.*')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Splits filename into parts separated by underscores.
|
|
99
|
+
#
|
|
100
|
+
# @param base_name [String] Filename without extension
|
|
101
|
+
# @return [Array<String>] Parts of the filename
|
|
102
|
+
def parse_parts(base_name)
|
|
103
|
+
base_name.split('_')
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|