<!-- definitions related to supporting SIAPv2.  In DaCHS, you can have
both a global SIAv2 service over the obscore view and individual,
collection-specific SIAv2 services; the obscore-based is the
#sitewide item below that you can publish using dachs pub //siap2. -->

<resource resdir="__system" schema="dc">
	<STREAM id="POSpar">
		<doc>A stream defining a SIAPv2-style POS par over an obscore table.

		You can pass the name of a pgsphere geometry in the geom_name macro.
		The default is set up for ivoa.obscore.
		</doc>
		<DEFAULTS geom_name="s_region"/>
		<condDesc>
			<inputKey name="POS" type="text" multiplicity="multiple" std="True"
				tablehead="Position"
				description="A spatial constraint using SIAPv2 CIRCLE, RANGE, or
					POLYGON shapes and respective values in decimal degrees.">
			</inputKey>
			<phraseMaker>
				<setup>
					<code>
						from gavo.protocols import siap
					</code>
				</setup>
				<code>
					for inStr in inPars["POS"]:
						yield '\geom_name &amp;&amp;%%(%s)s'%(
							base.getSQLKey("pos", siap.parseSIAP2Geometry(inStr), outPars))
				</code>
			</phraseMaker>
		</condDesc>
	</STREAM>

	<procDef id="intervalMatcher" type="phraseMaker">
		<doc>
			A DALI-style phrase maker that matches an interval against a pair
			of lower/upper bound columns.

			This is for using with multiplicity=multiple inputKeys.
		</doc>
		<setup>
			<par name="loColName" description="Name of the database column
				containing the lower bound"/>
			<par name="hiColName" description="Name of the database column
				containing the upper bound"/>
		</setup>
		<code>
			name = inputKeys[0].name
			for lower, upper in inPars[name]:
				# make BAND0=lowerVal predictable: build dict in two steps
				pars = {
					"lowerVal": base.getSQLKey(name, lower, outPars),
					"upperCol": hiColName,
					"lowerCol": loColName,}
				pars["upperVal"] = base.getSQLKey(name, upper, outPars)

				yield ("%%(%(upperVal)s)s >= %(lowerCol)s"
					" AND %(upperCol)s >= %%(%(lowerVal)s)s")%pars
		</code>
	</procDef>


	<procDef id="intervalConstraint" type="phraseMaker">
		<doc>
			A DALI-style phrase maker that matches an input interval against a
			a single value in the database.

			This is for using with multiplicity=multiple inputKeys.
		</doc>
		<setup>
			<par name="colName" description="Name of the database column
				containing the value constrained."/>
		</setup>
		<code>
			name = inputKeys[0].name
			for lower, upper in inPars[name]:
				yield ("%(colName)s BETWEEN"
					" %%(%(lowerVal)s)s AND %%(%(upperVal)s)s")%{
					"upperVal": base.getSQLKey(name, upper, outPars),
					"lowerVal": base.getSQLKey(name, lower, outPars),
					"colName": colName }
		</code>
	</procDef>


	<procDef id="equalityConstraint" type="phraseMaker">
		<doc>
			A DALI-style phrase maker that matches an input literally.

			This is for using with multiplicity=multiple inputKeys.
		</doc>
		<setup>
			<par name="colName" description="Name of the database column
				containing the value constrained."/>
		</setup>
		<code>
			name = inputKeys[0].name
			yield "%(colName)s in %%(%(key)s)s"%{
				"colName": colName,
				"key": base.getSQLKey(name, inPars[name], outPars)}
		</code>
	</procDef>


	<STREAM id="BANDpar">
		<doc>A stream defining the SIAPv2 BAND par over an obscore-like table.

		For non-obscore use, you can the min/max column names in the
		min_name and max_name macros.
		</doc>
		<DEFAULTS min_name="em_min" max_name="em_max"/>
		<condDesc>
			<inputKey name="BAND" type="double precision[2]" std="True"
				multiplicity="multiple" xtype="interval"
				tablehead="Wavelength"
				description="Wavelength interval that should intersect with
					 the dataset coverage"/>
			<phraseMaker procDef="//siap2#intervalMatcher">
				<bind key="loColName">"\min_name"</bind>
				<bind key="hiColName">"\max_name"</bind>
			</phraseMaker>
		</condDesc>
	</STREAM>

	<STREAM id="TIMEpar">
		<doc>A stream defining the SIAPv2 TIME par over an obscore-like table.

		For non-obscore use, you can the min/max column names in the
		min_name and max_name macros.
		</doc>
		<DEFAULTS min_name="t_min" max_name="t_max"/>
		<condDesc>
			<inputKey name="TIME" type="double precision[2]" std="True"
				multiplicity="multiple" xtype="interval"
				tablehead="Time"
				description="Time interval that should intersect with
					 the dataset coverage"/>
			<phraseMaker procDef="//siap2#intervalMatcher">
				<bind key="loColName">"\min_name"</bind>
				<bind key="hiColName">"\max_name"</bind>
			</phraseMaker>
		</condDesc>
	</STREAM>

	<STREAM id="POLpar">
		<doc>A stream defining the SIAPv2 POL par over an obscore-like table
		(i.e., one with pol_states).

		This can have multiple values, and the pol_states in obscore can have
		multiple states.  Hence, we need a somewhat tricky phrase maker.
		</doc>
		<DEFAULTS min_name="t_min" _max_name="t_max"/>
		<condDesc>
			<inputKey name="POL" type="text" std="True"
				multiplicity="multiple"
				tablehead="Polarisation"
				description="Polarisation states as per Obscore (i.e., from
					the set I Q U V RR LL RL LR XX YY XY YX POLI POLA"/>
			<phraseMaker>
				<code>
					name = inputKeys[0].name
					for val in inPars[name]:
						yield 'pol_states LIKE %%(%s)s'%(
							base.getSQLKey(name, "%%/%s/%%"%val, outPars))
				</code>
			</phraseMaker>
		</condDesc>
	</STREAM>


	<LOOP>
		<csvItems>
parName, colName,    tablehead,     unit, description
FOV,     s_fov,      Field of View, deg,  the field of view of the observation
SPATRES, s_resolution, Spat. Res.,  deg,  the spatial resolution of the image(s)
SPECRP,  em_res_power,Res. Power,    ,     the spectral resolving power λ/Δλ in spectrally resolved observations
EXPTIME, t_exptime,  Exposure Time, s,    the integration times of the observation
TIMERES, t_resolution,Time res.,    s,    the (absolute) resolution on the time axis
		</csvItems>
		<events passivate="True">
			<STREAM id="\parName\+par">
				<doc>A stream defining the SIAPv2 \parName parameter over an
				obscore-like table (i.e., one with \colName).

				For non-obscore use, you can override the colName macro to use
				another column than \colName.
				</doc>
				<DEFAULTS colName="\colName"/>
				<condDesc>
					<inputKey name="\parName" type="real[2]" xtype="interval" std="True"
						unit="\unit"
						multiplicity="multiple"
						tablehead="\tablehead"
						description="Lower and upper bound for \description"/>
					<phraseMaker procDef="//siap2#intervalConstraint">
						<bind key="colName">"\colName"</bind>
					</phraseMaker>
				</condDesc>
			</STREAM>
		</events>
	</LOOP>


	<LOOP>
		<csvItems>
parName,    colName,           tablehead,  type, description
ID,         obs_publisher_did, DID,        text, "A dataset identifier to match.  Note that contrary to the SIAP v2 REC, we do not compare the IVOIDs case-insensitively.  This should not be an issue if you got the IVOID from this service.  For IVOIDs obtained in other ways, you may need to use ILIKE or a similar facility through ObsTAP."
COLLECTION, obs_collection,    Collection, text, A name of a data collection within the service (use ObsTAP to find out the possible values).
FACILITY,   facility_name,     Facility,   text, A name of a facility (usually a telescope) used to make the observation.
INSTRUMENT, instrument_name,   Instrument, text, A name of an instrument used to acquire the data.
DPTYPE,     dataproduct_type,  Type,       text, Select either image or cube.
CALIB,      calib_level,       Calib. Level,smallint, "Calibration level of the data (0=raw data .. 3=processed science-ready data)"
TARGET,     target_name,       Target,     text, A name  of an observation target.
FORMAT,     access_format,     Format,     text, Media type (like application/fits) or similar of the data product searched
		</csvItems>
		<events passivate="True">
			<STREAM id="\parName\+par">
				<doc>A stream defining the SIAPv2 \parName parameter over an
				obscore-like table (i.e., one with \colName).
				</doc>
				<DEFAULTS colName="\colName"/>
				<condDesc>
					<inputKey name="\parName" type="\type" std="True"
						multiplicity="multiple"
						tablehead="\tablehead"
						description="\description"/>
						<phraseMaker procDef="//siap2#equalityConstraint">
							<bind key="colName">"\colName"</bind>
						</phraseMaker>
				</condDesc>
			</STREAM>
		</events>
	</LOOP>

	<STREAM id="parameters">
		<LOOP listItems="POS BAND TIME POL FOV SPATRES SPECRP EXPTIME TIMERES
				ID COLLECTION FACILITY INSTRUMENT DPTYPE CALIB TARGET FORMAT">
			<events passivate="True">
				<FEED source="\item\+par"/>
			</events>
		</LOOP>
	</STREAM>

	<mixinDef id="pgs">
		<doc>
			A mixin pulling in all columns necessary to support SIAP2.

			This is pulls in all obscore columns, including any you define locally
			in %#obscore-extracolumns.  In DaCHS, we additionally have the columns
			coming from the products table.  This latter fact means that the grammar
			filling tables mixing this in will need a `//products#define`_ rowfilter.

			To feed these tables, use the `//siap2#computePGS`_ and
			the `//siap2#setMeta`_ applies in the rowmaker.

			*Added in 2.7.3.*
		</doc>
		<mixinPar key="have_bandpass_id"
			description="Should the target table have an almost SIAP1-compatible
				bandpass_id column containing a free description of the filter
				(or other sort of bandpass)?  It doesn't hurt to have it if
				you have that sort of information (since 2.11.1).">False</mixinPar>

		<FEED source="//products#hackProductsData"/>
		<events>
			<FEED source="//products#tableRules" accrefName="access_url"/>
			<FEED source="//obscore#obscore-columns">
				<!-- we don't want explicit preview handling here; for SIAP2,
				people shouldn't store extra URIs for those (almost) ever.
				If we want preview columns in SIA (and nobody looks at them yet),
				let's compute them in the core. -->
				<PRUNE name="preview"/>
			</FEED>
		</events>
		<processEarly><code>
			from gavo import rscdef
			if base.parseBooleanLiteral(mixinPars["have_bandpass_id"]):
				substrate.feedObject("column",
					base.makeStruct(
						rscdef.Column,
						name="bandpass_id", type="text",
						ucd="instr.bandpass",
						tablehead="Bandpass",
						description="Free-form name of the bandpass (filter, detector,"
							" whatever else defines the spectral response) this"
							" observation was taken in.",
			 			verbLevel=10))
		</code></processEarly>
	</mixinDef>

	<procDef type="apply" id="computePGS">
		<doc>
			Computes the spatial coverage of an image based on WCS keys it
			looks for in the rawdict.

			The minimum is CRVAL1, CRVAL2, CRPIX1, CRPIX2, CRVAL1, CRVAL2, CUNIT1,
			CUNIT2, NAXISn, and CDi_j or CDELTn.  Interpretation of more
			WCS may or may not happen.  You can override the indexes of the
			spatial axes using naxis, which will of course change the
			parameter names, too.

			Records without or with insufficient wcs keys are furnished with
			all-NULL spatial columns if the missingIsError setup parameter
			is False, else they bomb out with a DataError (the default).

			This writes directly into rowdict; do *not* use ``idmaps="*"``
			on rowmakers with computePGS.

			*Added in 2.7.3.*
		</doc>

		<setup imports="gavo.protocols.siap">
			<par name="missingIsError" description="Throw an exception when
				no WCS information can be located.">True</par>
			<par name="naxis" description="Comma-separated list of integer
				axis indices (1=first) to be considered for WCS">"1,2"</par>
			<par name="ignoreBrokenWCS"
				description="If building the WCS transformation fails, null out
					the WCS parameters and continue with a warning (since 2.9.3)"
					>False</par>
			<code>
				WCSKEYS = ["s_region", "s_ra", "s_dec", "s_fov", "s_pixel_scale",
					"s_resolution"]
				NAXIS = [int(v) for v in naxis.split(",")]

				class PixelGauge(object):
					"""is a container for information about pixel sizes.

					It is constructed with an wcs.WCS instance and an (x, y)
					pair of pixel coordinates that should be close to the center
					of the frame.
					"""
					def __init__(self, wcs, centerPix):
						self.centerPos = coords.pix2sky(wcs, centerPix)
						self.pixelScale = coords.getPixelSizeDeg(wcs)
						offCenterPos = coords.pix2sky(wcs,
							(centerPix[0]+1, centerPix[1]+1))
						

				def computeFromWCS(vars, wcs, result):
					"""fills the spatial obscore fields in result from what it
					can make out in the wcs header.

					vars should be a FITS image header.
					"""
					result["mime"] = "application/fits"
					result["s_ra"], result["s_dec"
						] = coords.getCenterFromWCSFields(wcs)
					result["nAxes"] = int(vars["NAXIS"])
					axeInds = range(1, result["nAxes"]+1)
					dims = list(int(vars["NAXIS%d"%i])
						for i in axeInds)
					result["s_xel1"] = dims[0]
					result["s_xel2"] = dims[1]

					pixelGauge = PixelGauge(wcs, (dims[0]/2., dims[1]/2.))
					result["s_resolution"] = min(r*3600
						for r in coords.getPixelSizeDeg(wcs))
					result["s_pixel_scale"] = result["s_resolution"]
					result["s_region"] = coords.getSpolyFromWCSFields(wcs)
					# FoV computation assumes we always have rectangular
					# (4-vertex) polygons.  Can we do better than that?
					vertices = result["s_region"].asCooPairs()
					result["s_fov"] = coords.getGCDist(vertices[0], vertices[2])

				def nullOutWCS(result):
					"""clears all wcs fields, plus the ones in additonalKeys.
					"""
					for key in WCSKEYS:
						result[key] = None
				
				def addWCS(vars, result):
					wcs = coords.getWCS(vars, naxis=NAXIS)
					if wcs is None:
						result["s_xel1"] = int(vars["NAXIS1"])
						result["s_xel2"] = int(vars["NAXIS2"])
						if missingIsError:
							raise base.DataError("No WCS information")
						else:
							nullOutWCS(result)
					else:
						computeFromWCS(vars, wcs, result)
			</code>
		</setup>
		<code>
			try:
				addWCS(vars, result)
			except KeyError:
				# presumably some basic FITS keyword missing
				if missingIsError:
					raise
			except Exception as msg:
				# somehow broken WCS
				if ignoreBrokenWCS:
					nullOutWCS(result)
					base.ui.notifyWarning(f"Broken WCS ignored ({msg})")
				else:
					raise
		</code>
	</procDef>

	<procDef type="apply" id="setMeta">
		<doc>
			Fills non-spatial information in an obscore record for an image.

			If you define the bandpasses yourself, do *not* change
			bandpassUnit and give all values in metres.

			For optical images, we recommend to fill out bandpassId and then
			let the //siap2#getBandFromFilter apply compute the actual
			limits.

			Do *not* use ``idmaps="*"`` when using this procDef; it writes
			directly into result, and you would be clobbering what it does.

			The proc parameters use the obscore names wherever possible,
			but accept most of the names of the version 1 ``//siap#setMeta``
			and the ``//obscore#publishSIAP`` mixins.
			easy migration.

			*Added in 2.7.3.*
		</doc>
		<setup>
			<par key="dataproduct_type" alias="productType"
				description="Type of data in
				this table.  Must be a constant string taken from
				https://www.ivoa.net/rdf/product-type/">"image"</par>
			<par key="dataproduct_subtype" late="True"
				description="Closer specification of the sort of data this row
				is for.  It is almost always a good idea to leave this at None"
				>None</par>

			<par key="calib_level" late="True" alias="calibLevel"
				description="Calibration level
				of the image described here (see obscore docs for details).
				Images having WCS will generally have a 2 or 3 (if heavily
				resampled or stacked) here."
				>2</par>

			<par key="obs_collection" late="True" alias="collectionName"
				description="A shorthand
				for the data collection this image belongs to.  Make it really
				terse.  Freetext otherwise."
				/>
			<par key="obs_id" late="True" description="An identifier for
				an observation if that has yielded multiple rows.  Keep this
				None otherwise (DaCHS will then fill in the publisher DID)."
				>None</par>
			<par key="obs_title" alias="title" late="True" description="This
				should, in as few characters as possible, convey some idea what
				the image will show (e.g., instrument, object, bandpass)."
				>None</par>
			<par key="obs_publisher_did" late="True"
				description="Dataset identifier assigned by you.  If you want
				to re-publish this dataset in obscore, make this non-NULL."
				>\standardPubDID</par>
			<par key="obs_creator_did" late="True" alias="creatorDID"
				description="Identifier assigned by the creator.  This is
				rarely used."
				>None</par>

			<par key="target_name" late="True" alias="targetName"
				description="Target of a targeted observation.  Choose something
				Simbad can resolve if you can.">None</par>
			<par key="target_class" late="True" alias="targetClass"
				description="Class of the target of a targeted observation.
				Choose from http://www.ivoa.net/rdf/object-type (or have
				the vocabulary extended if your object type is missing)"
				>None</par>

			<par key="t_min" late="True" alias="tMin"
				description="MJD the first photons on this image were received.
				If you leave this as None, DaCHS will try to fill it up from
				dateObs and t_exptime."
				>None</par>
			<par key="t_max" late="True" alias="tMax"
				description="MJD the last photons on this image were received.
				If you leave this as None, DaCHS will try to fill it up from
				dateObs and t_exptime."
				>None</par>
			<par key="t_exptime" late="True"
				description="Total time in seconds the detector was collecting
					photons."
				>None</par>
			<par key="t_resolution" late="True" alias="tResolution"
				description="Minimal significant resolution on a time axis.
				Unless you have a space-time cube, leave at None."
				>None</par>

			<par key="em_min" alias="bandpassLo" late="True"
				description="Lower value of the wavelength covered, in meters
				(you usually want to use `//siap2#getBandFromFilter`_ to fill this
				from bandpassId)."
				>None</par>
			<par key="em_max" alias="bandpassHi" late="True"
				description="Upper value of the wavelength covered, in meters
				(you usually want to use `//siap2#getBandFromFilter`_ to fill this
				from bandpassId)."
				>None</par>
			<par key="em_res_power" late="True" description="Spectral
				resolving power λ/Δλ.  Unless you have a spectral cube, leave
				this at None."
				>None</par>
			<par key="em_ucd" description="Nature of the spectral axis
				in this data collection (when you have a spectral cube,
				probably one of em.freq, em.wl, or em.energy)"
				>None</par>

			<par key="o_ucd" alias="oUCD"
				description="UCD for the observable in this data collection"
				>"phot.count"</par>
			<par key="pol_states" late="True" alias="polStates"
				description="Polarisation states
				in this image.  See Obscore if you have polarimetric observations."
				>None</par>
			<par key="facility_name" late="True" alias="facilityName"
				description="Name of the facility at which data was taken."
				>base.getMetaText(targetTable, "facility", default=None)</par>
			<par key="instrument_name" alias="instrument" late="True"
				description="Name of the instrument that produced that data."
				>base.getMetaText(targetTable, "instrument", default=None)</par>

			<par key="t_xel" late="True" description="Number of time
				pixels in this image (make this None for stacked exposures)."
				>1</par>
			<par key="em_xel" late="True" description="Number of spectral
				pixels in this image."
				>1</par>
			<par key="pol_xel" late="True" description="Number of polarisation
				pixels in this image."
				>None</par>

			<par key="dateObs" late="True" description="The midpoint of the
				observation; this can either be a datetime instance, or
				a float>1e6 (a julian date) or something else (which is then
				interpreted as an MJD).  This is ignored if you give t_min
				and t_max">None</par>
			<par key="bandpass_id"
				alias="bandpassId"
				late="True"
				description="a rough indicator
				of the bandpass, like Johnson bands.  Only give if you want
				//siap2#getBandFromFilter to infer em_min and em_max.  This
				will only end up in the database table if you set
				have_bandpass_id to True in the mixin.">None</par>
		</setup>
		<code>
			# computed here
			result["source_table"] = @prodtblTable
			result["access_url"] = @prodtblAccref
			result["access_format"] = @prodtblMime
			result["access_estsize"] = @prodtblFsize and @prodtblFsize/1024

			# Stuff passed in by the user.  All par-s are copied into the
			# result below, but some of them are manipulated before.
			if dateObs is not None:
				dateObs = toMJD(dateObs)
				if t_exptime is not None:
					if t_min is None:
						t_min = dateObs-t_exptime/86400/2
					if t_max is None:
						t_max = dateObs+t_exptime/86400/2

			# obs_id needs to be non-NULL for now.  Hopefully, this
			# restriction can be lifted soon; in that case, just drop
			# the next line
			if obs_id is None:
				obs_id = obs_publisher_did
			
			for par in procDef.getSetupPars():
				parName = par.key
				result[parName] = eval(parName)
		</code>
	</procDef>

	<procDef type="apply" id="getBandFromFilter">
		<doc>
			sets the bandpassId, bandpassUnit, bandpassRefval, bandpassHi,
			and bandpassLo from a set of standard band Ids.

			The bandpass ids known are contained in a file supplied by
			DaCHS that you should consult for supported values by running
			``dachs admin dumpDF data/filters.txt``.

			If you pass in an unknown filter name, no keys will be generated,
			but no diagnostics will be emitted either.  Make sure to dachs info
			on the imported table if you expect no NULLs in the bandpass
			columns.

			All values filled in here are in meters.

			If this is used, it must run after //siap#setMeta since
			setMeta clobbers our result fields.

			*Added in 2.7.3.*
		</doc>
		<setup imports="gavo.base">
			<par key="sourceCol" description="Name of the column containing
				the filter name; leave at default None to take the band from
				result['bandpass_id'], where such information would be left
				by siap2#setMeta.">None</par>
			<code>
				_filterMap, _aliases = base.getFilterMap()

				def setBandpass(result, filterId):
					if not _filterMap:
						parseFilterMap()
					try:
						short, mid, long = _filterMap[_aliases.get(filterId, filterId)]
					except KeyError:  # nothing known, do nothing
						return
					result["em_min"] = short
					result["em_max"] = long
			</code>
		</setup>
		<code>
			if sourceCol is None:
				val = result['bandpass_id']
			else:
				val = vars[sourceCol]
			setBandpass(result, val)
		</code>
	</procDef>

	<condDesc id="humanInput">
		<inputKey name="hPOS" type="text" multiplicity="single"
			ucd="pos.eq"
			tablehead="Position"
			description="ICRS Position, RA,DEC, or Simbad object
				(e.g., 234.234,-32.45).">
			<property name="notForRenderer">siap.xml</property>
		</inputKey>
		<inputKey name="hSIZE" type="real"
			unit="deg"
			tablehead="Field size"
			description="Size in decimal degrees">
		</inputKey>

		<phraseMaker>
			<code>
				pos = inPars["hPOS"]
				try:
					ra, dec = base.parseCooPair(pos)
				except ValueError:
					data = base.caches.getSesame("web").query(pos)
					if not data:
						raise base.ValidationError("%r is neither a RA,DEC pair nor"
								" a simbad resolvable object"%inPars.get("hPOS", "Not given"),
							"hPOS")
					ra, dec = float(data["RA"]), float(data["dec"])
				yield "s_region &amp;&amp; %({})s".format(
					base.getSQLKey("roi", pgsphere.SCircle.fromDALI([
						ra, dec, inPars["hSIZE"]]), outPars))
			</code>
		</phraseMaker>
	</condDesc>

	<!-- we copy our STC coverage from obscore; run data limits //obscore
		to update the info here -->
	<coverage fallbackTo="__system__/obscore"/>
	
	<service id="sitewide" allowed="siap2.xml,form">
		<publish render="siap2.xml" sets="ivo_managed"/>
		<meta name="isServedBy"
			ivoId="ivo://\getConfig{ivoa}{authority}/__system__/obscore/obscore"
			>\getConfig{web}{siteName} Obscore</meta>
	
		<dbCore queriedTable="//obscore#ObsCore">
			<FEED source="parameters"/>

			<!-- force results to be images and cubes exclusively -->
			<condDesc combining="True">
				<phraseMaker>
					<code>
						yield "dataproduct_type in ('image', 'cube')"
					</code>
				</phraseMaker>
			</condDesc>
		</dbCore>
		<FEED source="%#sitewidesiap2-extras"/>
	</service>
</resource>
