Prefix taxonID and refine taxonRank heuristics

This commit is contained in:
karilint
2026-04-24 13:22:56 +03:00
parent 2632ff0dda
commit 41240747f5
3 changed files with 32 additions and 21 deletions
+27 -20
View File
@@ -116,21 +116,22 @@ const isMeaningfulTaxonName = (value: string | null): boolean => {
return true return true
} }
const containsDot = (value: string): boolean => value.includes('.')
const containsSpaceOrDot = (value: string): boolean => value.includes(' ') || value.includes('.')
const isSingleLowercaseWord = (value: string | null): boolean => { const isSingleLowercaseWord = (value: string | null): boolean => {
if (!isMeaningfulString(value)) return false if (!isMeaningfulString(value)) return false
const trimmed = value.trim() const trimmed = value.trim()
return /^[a-z]+$/.test(trimmed) return /^[a-z]+$/.test(trimmed)
} }
const isSpeciesSp = (value: string): boolean => /^sp\.?$/i.test(value.trim())
const includesIndet = (value: string): boolean => value.toLowerCase().includes('indet.')
const resolveTaxonRank = ({ const resolveTaxonRank = ({
family, family,
genus, genus,
specificEpithet, specificEpithet,
uniqueIdentifier, uniqueIdentifier,
superfamily, subclassOrSuperorderName,
subfamily, subfamily,
tribe, tribe,
subtribe, subtribe,
@@ -139,26 +140,32 @@ const resolveTaxonRank = ({
genus: string genus: string
specificEpithet: string specificEpithet: string
uniqueIdentifier: string | null uniqueIdentifier: string | null
superfamily: string subclassOrSuperorderName: string | null
subfamily: string subfamily: string
tribe: string tribe: string
subtribe: string subtribe: string
}): string => { }): string => {
if (family && containsDot(family)) return 'order' // Start from the lowest rank to the highest, but respect the indet.* rules which
if (genus && containsDot(genus)) return 'family' // indicate that lower taxa are unknown and we should not emit species/subspecies ranks.
if (specificEpithet && containsSpaceOrDot(specificEpithet)) return 'genus' const genusIndet = includesIndet(genus)
if (isSingleLowercaseWord(uniqueIdentifier)) return 'subspecies' const epithetIndet = includesIndet(specificEpithet)
if (uniqueIdentifier === '-') return 'species'
// If lower taxa are not meaningful (e.g. contain '.'), but a higher rank is known, if (!genusIndet && !epithetIndet) {
// pick the most specific available rank. const speciesSp = isSpeciesSp(specificEpithet)
const hasLowerTaxa = if (!speciesSp && isSingleLowercaseWord(uniqueIdentifier)) return 'subspecies'
isMeaningfulTaxonName(genus) || isMeaningfulTaxonName(specificEpithet) || isSingleLowercaseWord(uniqueIdentifier) if (uniqueIdentifier === '-') return 'species'
if (!hasLowerTaxa) { if (speciesSp) return 'species'
}
if (!genusIndet && epithetIndet) return 'genus'
if (genusIndet) {
if (subtribe) return 'subtribe' if (subtribe) return 'subtribe'
if (tribe) return 'tribe' if (tribe) return 'tribe'
if (subfamily) return 'subfamily' if (subfamily) return 'subfamily'
if (superfamily) return 'superfamily' if (!isMeaningfulString(subclassOrSuperorderName)) return 'family'
if (includesIndet(family)) return 'order'
return 'family'
} }
return 'species' return 'species'
@@ -201,10 +208,10 @@ export const mapSpeciesToTaxonRow = (species: SpeciesForTaxonExport): TaxonCsvRo
genus: genusName, genus: genusName,
specificEpithet: speciesName, specificEpithet: speciesName,
uniqueIdentifier: isMeaningfulString(species.unique_identifier) ? species.unique_identifier.trim() : null, uniqueIdentifier: isMeaningfulString(species.unique_identifier) ? species.unique_identifier.trim() : null,
superfamily,
subfamily, subfamily,
tribe, tribe,
subtribe, subtribe,
subclassOrSuperorderName: species.subclass_or_superorder_name,
}) })
const scientificName = (() => { const scientificName = (() => {
@@ -239,7 +246,7 @@ export const mapSpeciesToTaxonRow = (species: SpeciesForTaxonExport): TaxonCsvRo
})() })()
return { return {
taxonID: species.species_id.toString(), taxonID: `NOW:${species.species_id}`,
nomenclaturalCode: 'ICZN', nomenclaturalCode: 'ICZN',
scientificName, scientificName,
genericName, genericName,
@@ -707,7 +714,7 @@ const MEASUREMENT_FIELD_MAPPINGS: Array<{
] ]
export const mapSpeciesToMeasurementRows = (species: SpeciesForMeasurementExport): MeasurementCsvRow[] => { export const mapSpeciesToMeasurementRows = (species: SpeciesForMeasurementExport): MeasurementCsvRow[] => {
const taxonID = species.species_id.toString() const taxonID = `NOW:${species.species_id}`
return MEASUREMENT_FIELD_MAPPINGS.flatMap(mapping => { return MEASUREMENT_FIELD_MAPPINGS.flatMap(mapping => {
if (mapping.field === 'species_id') return [] if (mapping.field === 'species_id') return []
@@ -722,7 +729,7 @@ export const mapSpeciesToMeasurementRows = (species: SpeciesForMeasurementExport
return [ return [
{ {
taxonID, taxonID,
measurementID: `NOW:${taxonID}:${mapping.field.toString()}`, measurementID: `NOW:${species.species_id}:${mapping.field.toString()}`,
measurementType: mapping.measurementType, measurementType: mapping.measurementType,
verbatimMeasurementType: mapping.field.toString(), verbatimMeasurementType: mapping.field.toString(),
measurementValue, measurementValue,
@@ -26,7 +26,7 @@ describe('DwC-A export mapping', () => {
sp_comment: 'Test comment', sp_comment: 'Test comment',
}) })
expect(row.taxonID).toEqual('123') expect(row.taxonID).toEqual('NOW:123')
expect(row.nomenclaturalCode).toEqual('ICZN') expect(row.nomenclaturalCode).toEqual('ICZN')
expect(row.scientificName).toEqual('Felis catus Linnaeus, 1758') expect(row.scientificName).toEqual('Felis catus Linnaeus, 1758')
expect(row.genericName).toEqual('Felis') expect(row.genericName).toEqual('Felis')
@@ -101,6 +101,8 @@ describe('DwC-A export mapping', () => {
sp_status: null, sp_status: null,
}) })
expect(rows.some(row => row.taxonID === 'NOW:123')).toEqual(true)
const ids = rows.map(row => row.measurementID) const ids = rows.map(row => row.measurementID)
expect(ids).toContain('NOW:123:body_mass') expect(ids).toContain('NOW:123:body_mass')
expect(ids).toContain('NOW:123:diet2') expect(ids).toContain('NOW:123:diet2')
@@ -27,6 +27,7 @@ One row per `com_species` record.
Columns: Columns:
- `taxonID` = `com_species.species_id` - `taxonID` = `com_species.species_id`
- `taxonID` = `NOW:<species_id>`
- `nomenclaturalCode` = `ICZN` - `nomenclaturalCode` = `ICZN`
- `scientificName` = `${genus_name} ${species_name} ${sp_author}` (trimmed; authorship appended when present) - `scientificName` = `${genus_name} ${species_name} ${sp_author}` (trimmed; authorship appended when present)
- `genericName` = `genus_name` (only when `species_name` is a simple epithet; no spaces or dots) - `genericName` = `genus_name` (only when `species_name` is a simple epithet; no spaces or dots)
@@ -65,6 +66,7 @@ Long-format measurements linked by `taxonID`.
Columns: Columns:
- `taxonID` = `species_id` - `taxonID` = `species_id`
- `taxonID` = `NOW:<species_id>`
- `measurementID` = `NOW:<species_id>:<field_name>` - `measurementID` = `NOW:<species_id>:<field_name>`
- `measurementType` / `measurementUnit` / `measurementValue` per field mapping - `measurementType` / `measurementUnit` / `measurementValue` per field mapping
- `verbatimMeasurementType` = original DB field name (e.g. `diet1`, `body_mass`) - `verbatimMeasurementType` = original DB field name (e.g. `diet1`, `body_mass`)