app/models/georeference.rb
# A Georeference is an assertion that some shape, as derived from some method, describes the location of
# some CollectingEvent.
#
# A georeference contains three components:
# 1) A reference to a CollectingEvent (who, where, when, how)
# 2) A reference to a GeographicItem (a shape)
# 3) A method by which the shape was associated with the collecting event (via `type` subclassing).
# If a georeference was published its Source can be provided. This is not equivalent to providing a method for deriving the georeference.
#
# Contains information about a location on the face of the Earth, consisting of:
#
# @!attribute geographic_item_id
# @return [Integer]
# The id of a GeographicItem which represents the (non-error) representation of this georeference definition.
# Generally, it will represent a point.
#
# @!attribute collecting_event_id
# @return [Integer]
# The id of a CollectingEvent which represents the event of this georeference definition.
#
# @!attribute error_radius
# @return [Integer]
# the radius of the area of horizontal uncertainty of the accuracy of the location of
# this georeference definition. Measured in meters. Corresponding error areas are draw from the
# st_centroid() of the geographic item.
#
# @!attribute error_depth
# @return [Integer]
# The distance in meters of the radius of the area of vertical uncertainty of the accuracy of the location of
# this georeference definition.
#
# @!attribute error_geographic_item_id
# @return [Integer]
# The id of a GeographicItem which represents the (error) representation of this georeference definition.
# Generally, it will represent a polygon.
#
# @!attribute type
# @return [String]
# The type name of the this georeference definition.
#
# @!attribute position
# @return [Integer]
# An arbitrary ordering mechanism, the first georeference is routinely defaulted to in the application.
#
# @!attribute is_public
# @return [Boolean]
# True if this georeference can be shared, otherwise false.
#
# @!attribute api_request
# @return [String]
# The text of the GeoLocation request (::GeoLocate), or the verbatim data (VerbatimData).
#
# @!attribute project_id
# @return [Integer]
# the project ID
#
# @!attribute is_undefined_z
# @return [Boolean]
# True if this georeference cannot be located vertically, otherwise false.
#
# @!attribute is_median_z
# @return [Boolean]
# True if this georeference represents an average vertical distance, otherwise false.
#
# @!attribute year_georeferenced
# @return [Integer, nil]
# 4 digit year the georeference was *first* created/captured
#
# @!attribute month_georeferenced
# @return [Integer, nil]
#
# @!attribute day_georeferenced
# @return [Integer, nil]
#
class Georeference < ApplicationRecord
include Housekeeping
include SoftValidation
include Shared::Notes
include Shared::Tags
include Shared::ProtocolRelationships
include Shared::Citations
include Shared::DataAttributes
include Shared::Confidences # qualitative, not spatial
include Shared::Maps
include Shared::IsData
include Shared::Maps
attr_accessor :iframe_response # used to handle the geolocate from Tulane response
acts_as_list scope: [:collecting_event_id, :project_id], add_new_at: :top
belongs_to :collecting_event, inverse_of: :georeferences
belongs_to :error_geographic_item, class_name: 'GeographicItem', inverse_of: :georeferences_through_error_geographic_item
belongs_to :geographic_item, inverse_of: :georeferences
has_many :collection_objects, through: :collecting_event, inverse_of: :georeferences
has_many :otus, through: :collection_objects, source: 'otus'
has_many :georeferencer_roles, class_name: 'Georeferencer', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :georeference_authors, -> { order('roles.position ASC') }, through: :georeferencer_roles, source: :person # , inverse_of: :georeferences
validates :year_georeferenced, date_year: {min_year: 1000, max_year: Time.now.year }
validates :month_georeferenced, date_month: true
validates :day_georeferenced, date_day: {year_sym: :year_georeferenced, month_sym: :month_georeferenced},
unless: -> { year_georeferenced.nil? || month_georeferenced.nil? }
validates :collecting_event, presence: true
validates :collecting_event_id, uniqueness: { scope: [:type, :geographic_item_id, :project_id] }
validates :geographic_item, presence: true
validates :type, presence: true # TODO: technically not needed
validate :add_err_geo_item_inside_err_radius
validate :add_error_depth
validate :add_error_geo_item_intersects_area
validate :add_error_radius
validate :add_error_radius_inside_area
validate :add_obj_inside_area
validate :add_obj_inside_err_geo_item
validate :add_obj_inside_err_radius
validate :geographic_item_present_if_error_radius_provided
# validate :add_error_geo_item_inside_area
accepts_nested_attributes_for :geographic_item, :error_geographic_item
# @return [Boolean]
# When true, cascading cached values (e.g. in CollectingEvent) are not built
attr_accessor :no_cached
after_save :set_cached, unless: -> { self.no_cached || (self.collecting_event && self.collecting_event.no_cached == true) }
after_destroy :set_cached_collecting_event
def self.point_type
joins(:geographic_item).where(geographic_items: {type: 'GeographicItem::Point'})
end
# @param [Array] of parameters in the style of 'params'
# @return [Scope] of selected georeferences
def self.filter_by(params)
collecting_events = CollectingEvent.filter_by(params)
georeferences = Georeference.where('collecting_event_id in (?)', collecting_events.ids)
georeferences
end
# @param [Integer] geographic_item_id
# @param [Integer] distance
# @return [Scope] georeferences
# all georeferences within some distance of a geographic_item, by id
def self.within_radius_of_item(geographic_item_id, distance)
return where(id: -1) if geographic_item_id.nil? || distance.nil?
# sanitize_sql_array(["name=:name and group_id=:group_id", name: "foo'bar", group_id: 4])
# => "name='foo''bar' and group_id=4"
q1 = "ST_Distance(#{GeographicItem::GEOGRAPHY_SQL}, " \
"(#{GeographicItem.select_geography_sql(geographic_item_id)})) < #{distance}"
# q2 = ActiveRecord::Base.send(:sanitize_sql_array, ['ST_Distance(?, (?)) < ?',
# GeographicItem::GEOGRAPHY_SQL,
# GeographicItem.select_geography_sql(geographic_item_id),
# distance])
Georeference.joins(:geographic_item).where(q1)
end
# @param [String] locality string
# @return [Scope] Georeferences
# all Georeferences which are attached to a CollectingEvent which has a verbatim_locality which
# includes String somewhere
# Joins collecting_event.rb and matches %String% against verbatim_locality
# .where(id in CollectingEvent.where{verbatim_locality like "%var%"})
def self.with_locality_like(string)
with_locality_as(string, true)
end
# @param [String] locality string
# @return [Scope] Georeferences
# return all Georeferences which are attached to a CollectingEvent which has a verbatim_locality which
# equals String somewhere
# Joins collecting_event.rb and matches %String% against verbatim_locality
# .where(id in CollectingEvent.where{verbatim_locality like "%var%"})
def self.with_locality(string)
with_locality_as(string, false)
end
# @param [GeographicArea]
# @return [Scope] Georeferences
# returns all georeferences which have collecting_events which have geographic_areas which match
# geographic_areas as a GeographicArea
# TODO: or, (in the future) a string matching a geographic_area.name
def self.with_geographic_area(geographic_area)
partials = CollectingEvent.where(geographic_area:)
partial_gr = Georeference.where('collecting_event_id in (?)', partials.pluck(:id))
partial_gr
end
# TODO: not yet sure what the params are going to look like. what is below just represents a guess
# @param [ActionController::Parameters] arguments from _collecting_event_selection form
# @return [Array] of georeferences
def self.batch_create_from_georeference_matcher(arguments)
gr = Georeference.find(arguments[:georeference_id].to_param)
result = []
collecting_event_list = arguments[:checked_ids]
unless collecting_event_list.nil?
collecting_event_list.each do |event_id|
new_gr = Georeference.new(
collecting_event_id: event_id.to_i,
geographic_item_id: gr.geographic_item_id,
error_radius: gr.error_radius,
error_depth: gr.error_depth,
error_geographic_item_id: gr.error_geographic_item_id,
type: gr.type,
is_public: gr.is_public,
api_request: gr.api_request,
is_undefined_z: gr.is_undefined_z,
is_median_z: gr.is_median_z)
if new_gr.valid? # generally, this catches the case of multiple identical georeferences per collecting_event.
new_gr.save!
result.push new_gr
end
end
end
result
end
# @param [String, Boolean] String to find in collecting_event.verbatim_locality, Bool = false for 'Starts with',
# Bool = true if 'contains'
# @return [Scope] Georeferences which are attached to a CollectingEvent which has a verbatim_locality which
# includes, or is equal to 'string' somewhere
# Joins collecting_event.rb and matches %String% against verbatim_locality
# .where(id in CollectingEvent.where{verbatim_locality like "%var%"})
# TODO: Arelize
def self.with_locality_as(string, like)
likeness = like ? '%' : ''
query = "verbatim_locality #{like ? 'ilike' : '='} '#{likeness}#{string}#{likeness}'"
Georeference.where(collecting_event: CollectingEvent.where(query))
end
# @return [Hash]
# The interface to DwcOccurrence writiing for Georeference based values.
# See subclasses for super extensions.
def dwc_georeference_attributes(h = {})
georeferenced_by = if georeference_authors.any?
georeference_authors.collect{|a| a.cached}.join('|')
else
creator.name
end
h.merge!(
footprintWKT: geographic_item.to_wkt,
georeferenceVerificationStatus: confidences&.collect{|c| c.name}.join('; ').presence,
georeferencedBy: georeferenced_by,
georeferencedDate: created_at,
georeferenceProtocol: protocols.collect{|p| p.name}.join('|')
)
if geographic_item.type == 'GeographicItem::Point'
b = geographic_item.to_a
h[:decimalLongitude] = b.first
h[:decimalLatitude] = b.second
h[:coordinateUncertaintyInMeters] = error_radius
end
h
end
# @return [String, nil]
# the underscored version of the type, e.g. Georeference::GoogleMap => 'google_map'
def method_name
return nil if type.blank?
type.demodulize.underscore
end
# @return [GeographicItem, nil]
# a square which represents either the bounding box of the
# circle represented by the error_radius, or the bounding box of the error_geographic_item
# !! We assume the radius calculation is always larger (TODO: do we? discuss with Jim)
# TODO: cleanup, subclass, and calculate with SQL?
def error_box
retval = nil
if error_radius.nil?
retval = error_geographic_item.dup unless error_geographic_item.nil?
else
unless geographic_item.nil?
if geographic_item.geo_object_type
case geographic_item.geo_object_type
when :point
retval = Utilities::Geo.error_box_for_point(geographic_item.geo_object, error_radius)
when :polygon, :multi_polygon
retval = geographic_item.geo_object
end
end
end
end
retval
end
# @return [Rgeo::polygon, nil]
# a polygon representing the buffer
def error_radius_buffer_polygon
return nil if error_radius.nil? || geographic_item.nil?
sql_str = ActivRecord::Base.send(
:sanitize_sql_array,
['SELECT ST_Buffer(?, ?)',
geographic_item.geo_object.to_s,
(error_radius / Utilities::Geo::ONE_WEST_MEAN)])
value = GeographicItem.connection.select_all(sql_str).first['st_buffer']
Gis::FACTORY.parse_wkb(value)
end
# Called by Gis::GeoJSON.feature_collection
# @return [Hash] formed as a GeoJSON 'Feature'
def to_geo_json_feature
to_simple_json_feature.merge(
'properties' => {
'georeference' => {
'id' => id,
'tag' => "Georeference ID = #{id}"
}
}
)
end
# @return [Float]
def latitude
geographic_item.center_coords[0]
end
# @return [Float]
def longitude
geographic_item.center_coords[1]
end
# TODO: parametrize to include gazeteer
# i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
# @return [JSON Feature]
def to_simple_json_feature
geometry = RGeo::GeoJSON.encode(geographic_item.geo_object)
{
'type' => 'Feature',
'geometry' => geometry,
'properties' => {}
}
end
# Calculate the radius from error shapes
# !! Used to retroactively rebuild the radius from the polygon shape
def radius_from_error_shape
error_geographic_item&.radius
end
protected
def set_cached_collecting_event
collecting_event.send(:set_cached)
end
def set_cached
collecting_event.send(:set_cached_geographic_names)
end
# validation methods
# @return [Boolean]
# true if geographic_item is completely contained in error_geographic_item
def check_obj_inside_err_geo_item
# case 1
retval = true
unless geographic_item.nil? || !geographic_item.geo_object
unless error_geographic_item.nil?
if error_geographic_item.geo_object # is NOT false
retval = error_geographic_item.contains?(geographic_item.geo_object)
end
end
end
retval
end
# @return [Boolean]
# true if geographic_item is completely contained in error_box
# def check_obj_inside_err_radius
# # case 2
# retval = true
# if !error_radius.blank? && geographic_item && geographic_item.geo_object
# retval = error_box.contains?(geographic_item.geo_object)
# end
# retval
# end
# @return [Boolean]
# true if geographic_item is completely contained in error_box
def check_obj_inside_err_radius
# case 2
retval = true
# if !error_radius.blank? && geographic_item && geographic_item.geo_object
if error_radius.present?
if geographic_item.present?
if geographic_item.geo_object.present?
val = error_box
if val.present?
retval = val.contains?(geographic_item.geo_object)
end
end
end
end
retval
end
# @return [Boolean] true if error_geographic_item is completely contained in error_box
def check_err_geo_item_inside_err_radius
# case 3
retval = true
unless error_radius.nil?
unless error_geographic_item.nil?
if error_geographic_item.geo_object # is NOT false
retval = error_box.contains?(error_geographic_item.geo_object)
end
end
end
retval
end
# @return [Boolean] true if error_box is completely contained in
# collecting_event.geographic_area.default_geographic_item
def check_error_radius_inside_area
# case 4
retval = true
if collecting_event
ga_gi = collecting_event.geographic_area_default_geographic_item
eb = error_box
if error_radius.present? # rubocop:disable Style/IfUnlessModifier
retval = ga_gi.contains?(eb) if ga_gi && eb
end
end
retval
end
# @return [Boolean] true if error_geographic_item.geo_object is completely contained in
# collecting_event.geographic_area.default_geographic_item
def check_error_geo_item_inside_area
# case 5
retval = true
unless collecting_event.nil?
unless error_geographic_item.nil?
if error_geographic_item.geo_object # is NOT false
unless collecting_event.geographic_area.nil?
retval = collecting_event.geographic_area.default_geographic_item
.contains?(error_geographic_item.geo_object)
end
end
end
end
retval
end
# @return [Boolean] true if error_geographic_item.geo_object intersects (overlaps)
# collecting_event.geographic_area.default_geographic_item
def check_error_geo_item_intersects_area
# case 5.5
retval = true
if collecting_event.present?
if error_geographic_item.present?
if error_geographic_item.geo_object.present?
if collecting_event.geographic_area.present?
# !! TODO: check fir nil case
retval = collecting_event.geographic_area.default_geographic_item
.intersects?(error_geographic_item.geo_object)
end
end
end
end
retval
end
# @return [Boolean]
# true if geographic_item.geo_object is completely contained in collecting_event.geographic_area
# .default_geographic_item
def check_obj_inside_area
# case 6
retval = true
if collecting_event.present?
if geographic_item.present? && collecting_event.geographic_area.present?
if geographic_item.geo_object && collecting_event.geographic_area.default_geographic_item.present?
retval = collecting_event.geographic_area.default_geographic_item.contains?(geographic_item.geo_object)
end
end
end
retval
end
# @return [Boolean] true iff collecting_event contains georeference geographic_item.
def add_obj_inside_area
unless check_obj_inside_area
errors.add(
:geographic_item,
'for georeference is not contained in the geographic area bound to the collecting event')
errors.add(
:collecting_event,
'is assigned a geographic area which does not contain the supplied georeference/geographic item')
end
end
# @return [Boolean] true iff collecting_event area contains georeference error_geographic_item.
def add_error_geo_item_inside_area
unless check_error_geo_item_inside_area
problem = 'collecting_event geographic area must contain georeference error_geographic_item.'
errors.add(:error_geographic_item, problem)
errors.add(:collecting_event, problem)
end
end
# @return [Boolean] true iff collecting_event area intersects georeference error_geographic_item.
def add_error_geo_item_intersects_area
unless check_error_geo_item_intersects_area
problem = 'collecting_event geographic area must intersect georeference error_geographic_item.'
errors.add(:error_geographic_item, problem)
errors.add(:collecting_event, problem)
end
end
# @return [Boolean] true iff collecting_event area contains georeference error_radius bounding box.
def add_error_radius_inside_area
unless check_error_radius_inside_area
problem = 'collecting_event geographic area must contain georeference error_radius bounding box.'
errors.add(:error_radius, problem)
errors.add(:collecting_event, problem) # probably don't need error here
end
end
# @return [Boolean] true iff error_radius contains error_geographic_item.
def add_err_geo_item_inside_err_radius
unless check_err_geo_item_inside_err_radius # rubocop:disable Style/GuardClause
problem = 'error_radius must contain error_geographic_item.'
errors.add(:error_radius, problem)
errors.add(:error_geographic_item, problem)
end
end
# @return [Boolean] true iff error_radius contains geographic_item.
def add_obj_inside_err_radius
errors.add(:error_radius, 'must contain geographic_item.') unless check_obj_inside_err_radius
end
# @return [Boolean] true iff error_geographic_item contains geographic_item.
def add_obj_inside_err_geo_item
errors.add(:error_geographic_item, 'must contain geographic_item.') unless check_obj_inside_err_geo_item
end
# @return [Boolean] true iff error_depth is less than 8.8 kilometers (5.5 miles).
def add_error_depth
errors.add(:error_depth, 'error_depth must be less than 8.8 kilometers (5.5 miles).') if error_depth &&
error_depth > 8_800 # 8,800 meters
end
# @return [Boolean] true iff error_radius is less than 10 kilometers (6.6 miles).
def add_error_radius
if error_radius.present? && error_radius > 10_000 # 10 km
errors.add(:error_radius, ' must be less than 10 kilometers (6.6 miles).')
end
end
def geographic_item_present_if_error_radius_provided
if error_radius.present? &&
geographic_item_id.blank? && # provide existing
geographic_item.blank? # provide new
errors.add(:error_radius, 'can only be provided when geographic item is provided')
end
end
# TODO: Should be in lib/utilities/geo.rb.
# @param [Double] from_lat_
# @param [Double] from_lon_
# @param [Double] to_lat_
# @param [Double] to_lon_
# @return [Double] Heading is returned as an angle in degrees clockwise from North.
def heading(from_lat_, from_lon_, to_lat_, to_lon_)
from_lat_rad_ = RADIANS_PER_DEGREE * from_lat_
to_lat_rad_ = RADIANS_PER_DEGREE * to_lat_
delta_lon_rad_ = RADIANS_PER_DEGREE * (to_lon_ - from_lon_)
y_ = ::Math.sin(delta_lon_rad_) * ::Math.cos(to_lat_rad_)
x_ = ::Math.cos(from_lat_rad_) * ::Math.sin(to_lat_rad_) -
::Math.sin(from_lat_rad_) * ::Math.cos(to_lat_rad_) * ::Math.cos(delta_lon_rad_)
DEGREES_PER_RADIAN * ::Math.atan2(y_, x_)
end
end
#Dir[Rails.root.to_s + '/app/models/georeference/**/*.rb'].each { |file| require_dependency file }