Module: Vendor::Colrapi
- Defined in:
- lib/vendor/colrapi.rb,
lib/vendor/colrapi/name_translation.rb
Overview
A middle-layer wrapper between Colrapi and TaxonWorks
Defined Under Namespace
Modules: NameTranslation
Constant Summary collapse
- DATASETS =
{ col: '3LR', # The Human edited compilation col_extended: '3LXR' # Human plus algorithmic extensions }.freeze
- COL_ACCEPTED_STATUSES =
CoL usage statuses under which a name is in use.
['accepted', 'provisionally accepted'].freeze
- COL_FOLLOWED_STATUSES =
CoL usage statuses resolve_accepted_names follows to their accepted name.
['synonym', 'ambiguous synonym'].freeze
- COL_CODE_LOOKUP_MAP =
Maps a CoL nomenclatural code string to the TaxonWorks rank-name lookup (rank name => rank_class string) for that code.
Lookups can return nil, see COL_CODE_TO_NOMENCLATURE_CODE.
{ 'zoological' => ::ICZN_LOOKUP, 'botanical' => ::ICN_LOOKUP, 'bacterial' => ::ICNP_LOOKUP, 'virus' => ::ICVCN_LOOKUP }.freeze
- COL_CODE_TO_NOMENCLATURE_CODE =
Maps a CoL nomenclatural code string to the TaxonWorks nomenclature_code symbol (NomenclaturalRank.nomenclatural_code / rank_class.nomenclatural_code) it corresponds to.
Lookups can return nil: CoL may give no code for a name (name.code absent), or a code not mapped here (e.g. 'cultivars', 'phytosociological'). Callers must treat nil as "code unknown", not as a mismatch.
{ 'zoological' => :iczn, 'botanical' => :icn, 'bacterial' => :icnp, 'virus' => :icvcn }.freeze
- COL_CODE_ACRONYMS =
CoL's acronym for each of its nomenclatural codes (from its /vocab/nomCode; CoL gives phyto and phylo the same one).
{ 'zoological' => 'ICZN', 'botanical' => 'ICN', 'bacterial' => 'ICNP', 'virus' => 'ICVCN', 'cultivars' => 'ICNCP', 'phyto' => 'ICPN', 'phylo' => 'ICPN' }.freeze
Class Method Summary collapse
-
.above_genus_group?(rank_name, col_code) ⇒ Boolean
True when rank_name resolves to FamilyGroup or above (HigherClassificationGroup) — i.e.
-
.ancestors(dataset_id, taxon_id) ⇒ Array<Hash>
Returns the ancestor classification chain for a CoL taxon.
-
.ancestors_via_parent_id(dataset_id, taxon_id, max_depth: 20) ⇒ Array<Hash>
Builds an ancestor chain for external/denormed datasets by following the parentId field of successive taxon records.
-
.classified_usage(col_result, dataset_id: nil) ⇒ Hash
Builds CoL's own classification chain for a nameusage result — pure CoL data, no TaxonWorks matching.
-
.col_backbone_dataset?(dataset_id) ⇒ Boolean
Returns true when dataset_id refers to one of the CoL backbone datasets that support the classification subresource for ancestor retrieval.
-
.col_rank_sort(rank_name, col_code) ⇒ Object
Maps a CoL rank name ('genus', 'family', …) and CoL nomenclatural code ('zoological', 'botanical', 'bacterial', 'virus') to the TaxonWorks RANK_SORT index.
-
.datasets(q:, limit: 20) ⇒ Array<Hash>
Searches CoL datasets by name string.
-
.name_status(target) ⇒ Hash
What CoL says about the status of a name.
-
.protonym_name_from(name_hash) ⇒ String?
The TaxonWorks Protonym#name for a CoL name: the last epithet of a species-group name, a subgenus's epithet, or a uninomial (genus and above).
-
.published_rank(protonym) ⇒ String?
private
The rank protonym was published at, the finest rank of its original combination; nil without one.
-
.rank_class_for(rank_string, col_code: nil) ⇒ String?
Maps a human-readable CoL rank string to a TaxonWorks rank_class string.
-
.resolve_accepted_names(name_string, dataset_id: nil) ⇒ Array<Hash>
Resolves a free-text name (e.g. transcribed from a specimen label) to the names currently accepted by the Catalogue of Life, so a caller doesn't have to look up synonymy by hand before searching TaxonWorks.
-
.search(name_string, dataset_id: nil, limit: 20) ⇒ Hash
Searches the Catalogue of Life by name string - the exact string only (/nameusage?q=): no partial names, and a name with or without a subgenus misses the other form (see NameTranslation).
-
.usage_key(usage) ⇒ String?
private
Its comparison key.
-
.usage_status(usage, key) ⇒ Symbol?
private
What the usage says about the name, see .name_status.
Class Method Details
.above_genus_group?(rank_name, col_code) ⇒ Boolean
True when rank_name resolves to FamilyGroup or above (HigherClassificationGroup) — i.e. strictly coarser than GenusGroup — for the given CoL nomenclatural code. Unresolvable ranks are treated as not-above (false).
605 606 607 608 |
# File 'lib/vendor/colrapi.rb', line 605 def self.above_genus_group?(rank_name, col_code) rank_class = rank_class_for(rank_name, col_code:) rank_class ? ::FAMILY_AND_ABOVE_RANK_NAMES.include?(rank_class) : false end |
.ancestors(dataset_id, taxon_id) ⇒ Array<Hash>
Returns the ancestor classification chain for a CoL taxon.
Uses Colrapi.taxon with subresource: 'classification'. Response is an Array of hashes with keys: 'id', 'name' (String, not hash), 'authorship', 'rank', 'label', 'labelHtml'.
Only valid for backbone datasets (DATASETS, DATASETS). For external datasets use ancestors_via_parent_id instead.
242 243 244 245 246 247 248 249 |
# File 'lib/vendor/colrapi.rb', line 242 def self.ancestors(dataset_id, taxon_id) ::Colrapi.taxon( dataset_id, taxon_id: taxon_id, subresource: 'classification' ) rescue => e Rails.logger.warn "Vendor::Colrapi.ancestors error: #{e.}" [] end |
.ancestors_via_parent_id(dataset_id, taxon_id, max_depth: 20) ⇒ Array<Hash>
Builds an ancestor chain for external/denormed datasets by following the parentId field of successive taxon records.
External datasets (like the Mammal Diversity Database) are ingested into ChecklistBank without a pre-built classification subresource. Instead, each nameusage record carries a parentId pointing to the immediate parent within the same dataset.
Returns entries in the same format as the classification subresource used by ancestors():
{ 'id', 'name' (String, the full scientificName), 'rank', 'authorship',
'label', 'labelHtml' }
Order is distal-first (kingdom before immediate parent), matching ancestors(). !! The starting taxon itself is NOT included; only its ancestors are.
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 |
# File 'lib/vendor/colrapi.rb', line 282 def self.ancestors_via_parent_id(dataset_id, taxon_id, max_depth: 20) chain = [] visited = Set.new initial = ::Colrapi.taxon(dataset_id, taxon_id: taxon_id) return chain if initial.blank? current_id = initial['parentId'] max_depth.times do break if current_id.blank? || visited.include?(current_id) visited << current_id taxon = ::Colrapi.taxon(dataset_id, taxon_id: current_id) break if taxon.blank? chain << { 'id' => current_id, 'name' => taxon.dig('name', 'scientificName').to_s, 'rank' => taxon.dig('name', 'rank'), 'authorship' => taxon.dig('name', 'authorship'), 'label' => taxon.fetch('label', '').to_s, 'labelHtml' => taxon.fetch('labelHtml', '').to_s } current_id = taxon['parentId'] end # Return distal-first (kingdom before genus) to match the classification # subresource order returned by ancestors(). chain.reverse rescue => e Rails.logger.warn "Vendor::Colrapi.ancestors_via_parent_id error: #{e.}" [] end |
.classified_usage(col_result, dataset_id: nil) ⇒ Hash
Builds CoL's own classification chain for a nameusage result — pure CoL data, no TaxonWorks matching.
col_result is a flat nameusage hash as returned by search (no 'usage' wrapper):
{ 'id' => '6MB3T', 'status' => 'accepted',
'name' => { 'scientificName' => 'Homo sapiens', 'rank' => 'species',
'authorship' => 'Linnaeus, 1758',
'combinationAuthorship' => { 'authors' => [...], 'year' => '1758' } },
'label' => 'Homo sapiens Linnaeus, 1758', … }
Classification entries from ancestors() have:
{ 'id' => '636X2', 'name' => 'Homo', 'rank' => 'genus', 'label' => 'Homo', … }
Note: in classification entries 'name' is a plain String, not a hash: the full scientific name ('Olea europaea', 'Aedes (Stegomyia)').
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 |
# File 'lib/vendor/colrapi.rb', line 344 def self.classified_usage(col_result, dataset_id: nil) col_key = col_result['id'] col_name = protonym_name_from(col_result['name']) col_rank = col_result.dig('name', 'rank')&.downcase col_status = col_result['status'] = col_result.dig('name', 'authorship') col_year = col_result.dig('name', 'combinationAuthorship', 'year') || col_result.dig('name', 'basionymOrCombinationAuthorship', 'year') # CoL nomenclatural code: 'zoological', 'botanical', 'bacterial', 'virus' col_code = col_result.dig('name', 'code') # Dataset used for the search (target row). col_dataset_id = dataset_id.presence || DATASETS[:col] # A non-accepted name (synonym, ambiguous synonym, misapplied, ...) gets # no alignment. CoL hangs it off its accepted taxon, so the only # classification CoL has for it is the accepted name's - which says # where the taxon is now, nothing about the name's own genus or family. non_accepted = col_status.present? && !COL_ACCEPTED_STATUSES.include?(col_status.downcase) accepted_name = if col_status == 'synonym' && col_result['accepted'] { col_name: col_result.dig('accepted', 'name', 'scientificName'), col_authorship: col_result.dig('accepted', 'name', 'authorship') } end # Ancestors come from the searched dataset, so their ids are its ids. ancestor_chain = if non_accepted || col_key.blank? [] elsif col_backbone_dataset?(col_dataset_id) ancestors(col_dataset_id, col_key) else ancestors_via_parent_id(col_dataset_id, col_key) end # Drop 'domain', which CoL's backbone puts above kingdom in every # cellular lineage (Eukaryota, Bacteria, Archaea) and TaxonWorks has no # rank for. Other ranks TaxonWorks can't resolve (e.g. 'unranked' # Viruses, 'parvphylum') are kept and flagged :rank_unknown below, and # virus 'realm', above kingdom, is a TaxonWorks (ICVCN) rank. # Both ancestor sources are already distal→proximal (kingdom before # genus). ancestor_chain = ancestor_chain.reject { |a| a['rank']&.downcase == 'domain' } alignment = ancestor_chain.map do |ancestor| rank = ancestor['rank']&.downcase { rank:, # As CoL gives it ("Aedes (Stegomyia)", "Olea europaea"). col_name: ancestor['name'], col_id: ancestor['id'], dataset_id: col_dataset_id, col_authorship: ancestor['authorship'].presence, above_genus_group: above_genus_group?(rank, col_code), # e.g. 'parvphylum' - can't be created (ColCreator skips it) rank_unknown: rank_class_for(rank, col_code:).nil? } end { col_key:, col_name:, col_status:, col_authorship:, col_year:, col_rank:, col_code:, col_dataset_id:, alignment:, non_accepted:, **(accepted_name ? { accepted: accepted_name} : {}) } end |
.col_backbone_dataset?(dataset_id) ⇒ Boolean
Returns true when dataset_id refers to one of the CoL backbone datasets that support the classification subresource for ancestor retrieval. External/denormed datasets (e.g. Mammal Diversity Database, dataset 9802) do not have this subresource and require iterative parentId traversal instead.
258 259 260 |
# File 'lib/vendor/colrapi.rb', line 258 def self.col_backbone_dataset?(dataset_id) DATASETS.values.include?(dataset_id.to_s) end |
.col_rank_sort(rank_name, col_code) ⇒ Object
Maps a CoL rank name ('genus', 'family', …) and CoL nomenclatural code ('zoological', 'botanical', 'bacterial', 'virus') to the TaxonWorks RANK_SORT index. Higher index = more specific rank. Returns nil when unresolvable.
Unlike rank_class_for, doesn't fall back through the other codes: callers compare these indices against each other, so all must come from one code. An unrecognized col_code is treated as zoological.
594 595 596 597 598 599 |
# File 'lib/vendor/colrapi.rb', line 594 def self.col_rank_sort(rank_name, col_code) return nil if rank_name.blank? lookup = COL_CODE_LOOKUP_MAP[col_code] || ::ICZN_LOOKUP rank_class = lookup[rank_name] rank_class ? ::RANK_SORT[rank_class] : nil end |
.datasets(q:, limit: 20) ⇒ Array<Hash>
Searches CoL datasets by name string.
Returns an array of dataset summaries, each containing at least 'id', 'title', and 'alias'.
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 |
# File 'lib/vendor/colrapi.rb', line 192 def self.datasets(q:, limit: 20) # Text search by title/name. text_results = begin result = ::Colrapi.dataset(q: q, limit: limit) (result['result'] || []).map { |d| { 'id' => d['key'].to_s, 'title' => d['title'], 'alias' => d['alias'] } } rescue => e Rails.logger.warn "Vendor::Colrapi.datasets text search error: #{e.}" [] end # Direct lookup by dataset ID — `q` may itself be a key like '3LXR'. # Colrapi.dataset(dataset_id:) returns a single hash, not a paged result. direct_hit = begin d = ::Colrapi.dataset(dataset_id: q) d.is_a?(Hash) && d['key'].present? ? { 'id' => d['key'].to_s, 'title' => d['title'], 'alias' => d['alias'] } : nil rescue nil end seen = {} [direct_hit, *text_results].compact.each_with_object([]) do |d, arr| next if seen[d['id']] seen[d['id']] = true arr << d end end |
.name_status(target) ⇒ Hash
What CoL says about the status of a name.
CoL's fuzzy /nameusage/search is queried with NameTranslation.search_term, and its usages are compared to the name by NameTranslation key, not exact string - CoL doesn't write a name the same way TaxonWorks does, or even consistently (see NameTranslation). A usage whose key matches makes the name
- :accepted - accepted, or a synonym of a name with the same key (e.g. an original combination CoL lists under its own species)
- :synonym - a synonym of a different name, i.e. outdated
- :ambiguous_synonym - an ambiguous synonym (several possible targets) in that order of precedence. With no matching usage it's :undeterminable.
A TaxonName is looked up by one of its NameTranslation.forms: an invalid Protonym by its published form (original combination), since CoL lists synonyms as published while TaxonWorks moves a synonym under its valid name's parent (project 13 sample, 2026-10: of 40 synonyms whose two forms differ, CoL had the original combination for 38 and #cached for 7, never #cached alone); anything else by its current form (a Combination, which has none, by its published one). A published form is searched at the rank it was published at.
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 |
# File 'lib/vendor/colrapi.rb', line 53 def self.name_status(target) if target.is_a?(::Combination) # Only a published form, at the rank it was published at (its finest # element), which needn't be its protonym's rank now. name = NameTranslation.forms(target)[:published] rank = target.protonyms_by_rank.keys.last elsif target.is_a?(::TaxonName) forms = NameTranslation.forms(target) invalid_protonym = target.is_a?(::Protonym) && !target.cached_is_valid rank = ::RANKS_LOOKUP[target.rank_class.to_s] if invalid_protonym && forms[:published] # At the rank it was published at, e.g. a species published as a # variety, or a subgenus as a genus. name = forms[:published] rank = published_rank(target) || rank else name = forms[:current] end else name = target # TODO: try harder here. rank = 'species' end key = NameTranslation.name_key(name) results = if key.present? ::Colrapi.nameusage_search( q: NameTranslation.search_term(name), dataset_id: DATASETS[:col], rank: )['result'] end statuses = [] col_usages = (results || []).map do |result| usage = result['usage'] statuses << usage_status(usage, key) if usage_key(usage) == key accepted = {} if usage['accepted'] accepted = { name: usage.dig('accepted', 'name', 'scientificName'), status: usage.dig('accepted', 'status') } end { usage: { name: usage.dig('name', 'scientificName'), status: usage['status'] }, accepted: } end provisional_status = [:accepted, :synonym, :ambiguous_synonym] .find { |status| statuses.include?(status) } || :undeterminable { taxonworks_name: name, col_usages:, provisional_status: } end |
.protonym_name_from(name_hash) ⇒ String?
The TaxonWorks Protonym#name for a CoL name: the last epithet of a species-group name, a subgenus's epithet, or a uninomial (genus and above). CoL's scientificName is the full name ("Homo sapiens", "Aedes (Stegomyia)"); CoL parses a subgenus into genus + infragenericEpithet, with no uninomial. Priority: infraspecificEpithet > specificEpithet > infragenericEpithet
uninomial > scientificName fallback.
511 512 513 514 515 516 517 518 |
# File 'lib/vendor/colrapi.rb', line 511 def self.protonym_name_from(name_hash) return nil if name_hash.nil? name_hash['infraspecificEpithet'].presence || name_hash['specificEpithet'].presence || name_hash['infragenericEpithet'].presence || name_hash['uninomial'].presence || name_hash['scientificName'] end |
.published_rank(protonym) ⇒ String? (private)
Returns the rank protonym was published at, the finest rank of its original combination; nil without one.
120 121 122 123 124 125 |
# File 'lib/vendor/colrapi.rb', line 120 def self.published_rank(protonym) types = protonym.original_combination_relationships.pluck(:type) ::ORIGINAL_COMBINATION_RANKS .select { |_rank, type| types.include?(type) } .keys.last&.to_s end |
.rank_class_for(rank_string, col_code: nil) ⇒ String?
Maps a human-readable CoL rank string to a TaxonWorks rank_class string.
When col_code is recognized (e.g. 'botanical' → ICN_LOOKUP), only that code's lookup is used: a rank class from another code would fail Protonym's same-code validation against its parent (a zoological 'variety' is unknown, not ICN's). Otherwise falls back through all four codes in order so any rank resolves if possible.
571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 |
# File 'lib/vendor/colrapi.rb', line 571 def self.rank_class_for(rank_string, col_code: nil) return nil if rank_string.blank? r = rank_string.to_s.downcase if (lookup = COL_CODE_LOOKUP_MAP[col_code]) return lookup[r] end COL_CODE_LOOKUP_MAP.values.each do |lookup| result = lookup[r] return result if result end nil end |
.resolve_accepted_names(name_string, dataset_id: nil) ⇒ Array<Hash>
Resolves a free-text name (e.g. transcribed from a specimen label) to the names currently accepted by the Catalogue of Life, so a caller doesn't have to look up synonymy by hand before searching TaxonWorks.
Every exact hit for name_string is resolved, in CoL's order: an accepted (or provisionally accepted) name to itself, a synonym to its accepted target. Several hits are homonyms - the same name in different codes (Pieris), or a later homonym CoL lists as a synonym of its replacement (Abies alba (Aiton) Michx. -> Picea glauca) - so each is resolved rather than picking one.
An ambiguous synonym is followed too. Its accepted is only one of the
name's possible targets - a pro parte synonym (Abax brunnipes Letzner,
1852) is one hit per target - so the caller should say so; each target
is still a valid name the user may mean.
Anything else is skipped. A misapplied name also carries an accepted
usage, but it's the taxon some author misidentified as the name, not
what the name means.
Several hits can resolve to the same accepted name; it's given once, and the hit kept for it (which the caller's "valid name for" note names) is, in order of preference: the accepted name itself, a plain synonym, an ambiguous synonym. Ties go to the hit CoL lists first.
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 |
# File 'lib/vendor/colrapi.rb', line 448 def self.resolve_accepted_names(name_string, dataset_id: nil) target_dataset_id = dataset_id.presence || DATASETS[:col] hits = search(name_string, dataset_id:)['result'] || [] resolved = hits.filter_map do |hit| matched_status = hit['status'] if COL_FOLLOWED_STATUSES.include?(matched_status) accepted = hit['accepted'] end next if accepted.nil? && !COL_ACCEPTED_STATUSES.include?(matched_status.to_s.downcase) # The full nameusage hash for the *valid* name - CoL nests the # accepted usage (a complete nameusage record, same shape as a search # result) right on the synonym hit. valid_col_result = accepted || hit { input: name_string, # Full scientific name (e.g. "Homo sapiens"), not the single-word # component protonym_name_from extracts for building TW Protonym # trees - TaxonName/Otu#cached holds the full combination, so # that's what a caller needs to search against. matched_name: hit.dig('name', 'scientificName'), matched_authorship: hit.dig('name', 'authorship').presence, matched_status:, matched_col_key: hit['id'], valid_name: valid_col_result.dig('name', 'scientificName'), valid_col_key: valid_col_result['id'], valid_authorship: valid_col_result.dig('name', 'authorship'), valid_status: accepted ? (accepted['status'] || 'accepted') : matched_status, valid_col_result:, dataset_id: target_dataset_id } end resolved .group_by { |r| r[:valid_col_key] } .values .map do |same_valid_name| same_valid_name.min_by do |r| if r[:matched_col_key] == r[:valid_col_key] 0 elsif r[:matched_status] == 'synonym' 1 else 2 end end end end |
.search(name_string, dataset_id: nil, limit: 20) ⇒ Hash
Searches the Catalogue of Life by name string - the exact string only (/nameusage?q=): no partial names, and a name with or without a subgenus misses the other form (see NameTranslation).
The Colrapi gem takes dataset_id as a positional first argument. Response structure: { 'total' => Integer, 'result' => Array } Each result entry is the name usage itself (not wrapped in 'usage' as nameusage_search entries are), with keys:
'id', 'status', 'name' (hash with 'scientificName', 'rank', 'authorship', …),
'label', 'labelHtml', 'parentId', etc.
176 177 178 179 180 181 182 |
# File 'lib/vendor/colrapi.rb', line 176 def self.search(name_string, dataset_id: nil, limit: 20) target = dataset_id.presence || DATASETS[:col] ::Colrapi.nameusage(target, q: name_string, limit:) rescue => e Rails.logger.warn "Vendor::Colrapi.search error: #{e.}" { 'total' => 0, 'result' => [] } end |
.usage_key(usage) ⇒ String? (private)
Returns its comparison key.
130 131 132 133 134 |
# File 'lib/vendor/colrapi.rb', line 130 def self.usage_key(usage) NameTranslation.col_name_key( usage.dig('name', 'scientificName'), usage.dig('name', 'rank') ) end |
.usage_status(usage, key) ⇒ Symbol? (private)
Returns what the usage says about the name, see .name_status.
140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 |
# File 'lib/vendor/colrapi.rb', line 140 def self.usage_status(usage, key) case usage['status'] when *COL_ACCEPTED_STATUSES :accepted when 'synonym' # A synonym of a name with the same key is the same name, so it isn't # outdated. CoL lists "Aphrophora ambigua" (the original combination, # no subgenus) as a synonym of accepted "Aphrophora (Aphrophora) # ambigua". accepted_key = NameTranslation.col_name_key( usage.dig('accepted', 'name', 'scientificName'), usage.dig('accepted', 'name', 'rank') ) accepted_key == key ? :accepted : :synonym when 'ambiguous synonym' :ambiguous_synonym end end |