Class: Match::Otu::TaxonName

Inherits:
Object
  • Object
show all
Defined in:
lib/match/otu/taxon_name.rb

Constant Summary collapse

MAX_NAMES =
3000
MATCHABLE_COLUMNS =
[
  :cached, :cached_secondary_homonym, :cached_primary_homonym
].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(names:, project_id:, levenshtein_distance: 0, taxon_name_id: nil, resolve_synonyms: false, try_without_subgenus: false) ⇒ TaxonName

Returns a new instance of TaxonName.

Parameters:

  • names (Array<String>)

    array of name strings to match

  • project_id (Integer)
  • levenshtein_distance (Integer) (defaults to: 0)

    0 for exact, 1-8 for fuzzy

  • taxon_name_id (Integer, nil) (defaults to: nil)

    scope matches to descendants of this TaxonName

  • resolve_synonyms (Boolean) (defaults to: false)

    when true, resolve synonyms to valid names and return their OTUs

  • try_without_subgenus (Boolean) (defaults to: false)

    when true and cached match fails, try cached_secondary_homonym then cached_primary_homonym



42
43
44
45
46
47
48
49
# File 'lib/match/otu/taxon_name.rb', line 42

def initialize(names:, project_id:, levenshtein_distance: 0, taxon_name_id: nil, resolve_synonyms: false, try_without_subgenus: false)
  @names = names.first(MAX_NAMES)
  @project_id = project_id
  @levenshtein_distance = levenshtein_distance.to_i.clamp(0, 8)
  @taxon_name_id = taxon_name_id
  @resolve_synonyms = resolve_synonyms
  @try_without_subgenus = try_without_subgenus
end

Instance Attribute Details

#levenshtein_distanceObject (readonly)

Returns the value of attribute levenshtein_distance.



34
35
36
# File 'lib/match/otu/taxon_name.rb', line 34

def levenshtein_distance
  @levenshtein_distance
end

#namesObject (readonly)

Returns the value of attribute names.



34
35
36
# File 'lib/match/otu/taxon_name.rb', line 34

def names
  @names
end

#project_idObject (readonly)

Returns the value of attribute project_id.



34
35
36
# File 'lib/match/otu/taxon_name.rb', line 34

def project_id
  @project_id
end

#resolve_synonymsObject (readonly)

Returns the value of attribute resolve_synonyms.



34
35
36
# File 'lib/match/otu/taxon_name.rb', line 34

def resolve_synonyms
  @resolve_synonyms
end

#taxon_name_idObject (readonly)

Returns the value of attribute taxon_name_id.



34
35
36
# File 'lib/match/otu/taxon_name.rb', line 34

def taxon_name_id
  @taxon_name_id
end

#try_without_subgenusObject (readonly)

Returns the value of attribute try_without_subgenus.



34
35
36
# File 'lib/match/otu/taxon_name.rb', line 34

def try_without_subgenus
  @try_without_subgenus
end

Instance Method Details

#base_scopeActiveRecord::Relation (private)

Build the base TaxonName scope, optionally constrained to descendants of taxon_name_id.

Returns:

  • (ActiveRecord::Relation)


157
158
159
160
161
162
163
164
165
166
167
# File 'lib/match/otu/taxon_name.rb', line 157

def base_scope
  scope = ::TaxonName.where(project_id: project_id)

  if taxon_name_id.present?
    scope = scope
      .joins('JOIN taxon_name_hierarchies ON taxon_names.id = taxon_name_hierarchies.descendant_id')
      .where(taxon_name_hierarchies: { ancestor_id: taxon_name_id })
  end

  scope
end

#callArray<Hash>

Returns:

  • (Array<Hash>)


52
53
54
55
56
57
58
59
60
61
# File 'lib/match/otu/taxon_name.rb', line 52

def call
  unique_names = names.uniq
  match_cache = {}

  unique_names.each do |name|
    match_cache[name] = match_name(name)
  end

  names.map { |name| match_cache[name].merge(scientific_name: name) }
end

#find_taxon_names(name, column: :cached) ⇒ Array<TaxonName> (private)

Parameters:

  • name (String)
  • column (Symbol) (defaults to: :cached)

    :cached, :cached_secondary_homonym, or :cached_primary_homonym

Returns:

Raises:

  • (ArgumentError)


112
113
114
115
116
117
118
119
120
# File 'lib/match/otu/taxon_name.rb', line 112

def find_taxon_names(name, column: :cached)
  raise ArgumentError, "Invalid column: #{column}" unless MATCHABLE_COLUMNS.include?(column)

  if levenshtein_distance > 0
    find_taxon_names_fuzzy(name, column:)
  else
    find_taxon_names_exact(name, column:)
  end
end

#find_taxon_names_exact(name, column: :cached) ⇒ Array<TaxonName> (private)

Parameters:

  • name (String)
  • column (Symbol) (defaults to: :cached)

Returns:



125
126
127
128
# File 'lib/match/otu/taxon_name.rb', line 125

def find_taxon_names_exact(name, column: :cached)
  scope = base_scope
  scope.where(column => name).to_a
end

#find_taxon_names_fuzzy(name, column: :cached) ⇒ Array<TaxonName> (private)

Parameters:

  • name (String)
  • column (Symbol) (defaults to: :cached)

Returns:



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/match/otu/taxon_name.rb', line 133

def find_taxon_names_fuzzy(name, column: :cached)
  scope = base_scope
  truncated_name = name[0..254]
  qualified_column = "taxon_names.#{column}"

  scope
    .where(
      "levenshtein(left(#{qualified_column}, 255), ?) <= ?",
      truncated_name,
      levenshtein_distance
    )
    .order(
      Arel.sql(
        ::TaxonName.sanitize_sql_array(
          ["levenshtein(left(#{qualified_column}, 255), ?)", truncated_name]
        )
      )
    )
    .limit(10)
    .to_a
end

#genuinely_ambiguous?(ranked) ⇒ Boolean (private)

Multiple candidate rows aren't ambiguous if they all resolve to the same valid taxon (e.g. a Combination alongside its own Protonym) — ranking always picks correctly there. Only flag it when candidates point to genuinely different valid taxa (e.g. true homonyms).

Parameters:

Returns:

  • (Boolean)


105
106
107
# File 'lib/match/otu/taxon_name.rb', line 105

def genuinely_ambiguous?(ranked)
  ranked.map(&:cached_valid_taxon_name_id).uniq.length > 1
end

#match_name(name) ⇒ Hash (private)

Parameters:

  • name (String)

Returns:

  • (Hash)


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
# File 'lib/match/otu/taxon_name.rb', line 67

def match_name(name)
  taxon_names = find_taxon_names(name)

  if taxon_names.empty? && try_without_subgenus
    taxon_names = find_taxon_names(name, column: :cached_secondary_homonym)
    if taxon_names.empty?
      taxon_names = find_taxon_names(name, column: :cached_primary_homonym)
    end
  end

  return { taxon_name_id: nil, taxon_name: nil, otus: [], ambiguous: false, matched: false } if taxon_names.empty?

  ranked = rank_taxon_names(taxon_names)
  matched = ranked.first
  resolved = matched

  if resolve_synonyms && matched.cached_valid_taxon_name_id != matched.id
    valid = ::TaxonName.where(project_id: project_id).find_by(id: matched.cached_valid_taxon_name_id)
    resolved = valid if valid
  end

  otus = ::Otu.where(project_id: project_id, taxon_name_id: resolved.id).to_a

  {
    taxon_name_id: resolved.id,
    taxon_name: resolved,
    otus: otus,
    ambiguous: genuinely_ambiguous?(ranked),
    matched: true
  }
end

#rank_taxon_names(taxon_names) ⇒ Array<TaxonName> (private)

Rank candidate TaxonNames:

1. Prefer those with OTUs
2. Prefer valid names

Parameters:

Returns:



174
175
176
177
178
179
180
181
182
183
184
# File 'lib/match/otu/taxon_name.rb', line 174

def rank_taxon_names(taxon_names)
  taxon_name_ids = taxon_names.map(&:id)
  ids_with_otus = ::Otu.where(project_id: project_id, taxon_name_id: taxon_name_ids).distinct.pluck(:taxon_name_id).to_set

  taxon_names.sort_by do |tn|
    [
      ids_with_otus.include?(tn.id) ? 0 : 1,
      tn.cached_valid_taxon_name_id == tn.id ? 0 : 1
    ]
  end
end