API documentation¶
Generated from the docstrings. For an introduction to what these do and how they fit together, start with the User guide.
divtel.telescope¶
The two classes you build a simulation from.
Telescope is one telescope on the ground.
Array is a list of them, plus everything about the
array as a whole: where it points, how much sky it sees, how to write that
out.
- class divtel.telescope.Array(telescope_list)¶
Bases:
objectDescribe a telescope array
- Parameters:
telescope_list ([Telescope]) – list of telescopes forming the array
- property barycenter¶
Barycenter of the array
- Returns:
[x, y, z] in metres
- Return type:
astropy.Quantity
- display_2d(projection='xy', ax=None, **kwargs)¶
Display the array
- Parameters:
projection (str) – ‘xy’, ‘xz’ or ‘yz’
ax (matplotlib.pyplot.axes)
kwargs (args for pyplot.scatter)
- Returns:
ax
- Return type:
matplotlib.pyplot.axes
- display_3d()¶
Display the array in 3d, with an arrow per telescope pointing
- Returns:
ax
- Return type:
matplotlib.pyplot.axes
- property div¶
Divergence parameter last passed to divergent_pointing
- Return type:
float
- divergent_pointing(div, alt_mean, az_mean)¶
Divergent pointing given a parameter div. Update pointing of all telescopes of the array
- Parameters:
div (float between 0 and 1)
alt_mean (astropy.Quantity) – mean alt pointing
az_mean (astropy.Quantity) – mean az pointing
- Raises:
ValueError – if div is outside [0, 1]
Notes
Divergence spreads telescopes on both sides of the mean pointing, so one asked for near the horizon pushes some of them underground – a real mount cannot follow that, it rails at its lowest elevation and stops. A telescope whose computed altitude comes out negative is pointed at the horizon instead (altitude 0), at whatever azimuth the geometry gave it; nothing else about the array’s pointing changes.
- export_cfg(filename=None, outdir='./', tel_configs=None, verbose=False)¶
Write the array pointing as a sim_telarray configuration file.
sim_telarray selects a telescope by defining TELESCOPE, so the file is one #if/#elif chain: block 0 holds the array-wide defaults and blocks 1..N hold one telescope each.
Angles are converted to sim_telarray’s convention, which is not divtel’s: it takes a zenith angle rather than an altitude, and its azimuth runs the opposite way round, so
TELESCOPE_THETA = 90 - alt TELESCOPE_PHI = (360 - az) mod 360
- Parameters:
filename (str, optional) – defaults to ‘CTA-ULTRA6-LaPalma-divX-azX-altX.cfg’, built from the divergence and mean pointing of the array
outdir (str or pathlib.Path, optional) – directory to write into; must exist
tel_configs ([str], optional) – config file each telescope includes, in array order. Defaults to the La Palma layout the writer was built for: the first four telescopes are LSTs, the rest MSTs with NectarCam.
verbose (bool, optional) – echo the file to stdout once written
- Returns:
the file that was written
- Return type:
pathlib.Path
- Raises:
ValueError – if the array has never been pointed, or tel_configs does not have one entry per telescope
- group_by(groups)¶
Split the array into sub-arrays.
A real array is not one instrument but several sharing a site: on the CTA La Palma layout four LSTs sit inside fifteen MSTs, with different cameras, different fields of view, and their own barycenters. Grouping lets each be looked at on its own.
- Parameters:
groups (dict or str) –
a mapping of group name to the telescope ids in it, e.g.
{"LST": [1, 2, 3, 4], "MST": range(5, 20)}, or one of two strings that find the groups without being told them:"camera_radius"groups by the camera a telescope carries, and"fov_radius"by the angular radius that camera subtends.The two agree on the CTAO layouts shipped here and need not agree in general: what a telescope sees off-axis is set by camera radius over focal length, so two designs with different cameras can share an angular radius and two with the same camera can differ. The angle is what the geometry uses – divtel.strategy treats it as a telescope’s type throughout – so prefer
"fov_radius"when the question is about reach rather than about hardware.- Returns:
one sub-array per group, in the order the groups were given
- Return type:
dict of str to Array
- Raises:
ValueError – if an id is not in the array, or a telescope is put in two groups
Notes
Ids are Telescope.id, not positions in the array. CTAO numbers telescopes by type – LSTs 1 to 4, MSTs 5 to 14, telescopes added to try out a configuration from 15 on – and divtel.layout.load_array takes those ids from the layout file, so they mean what the layout says.
The sub-arrays hold the same Telescope objects as this array rather than copies, so pointing the whole array points every group with it. Each sub-array is handed the array’s current pointing, so hyper_fov and export_cfg work on it straight away.
Telescopes may be left out of every group; nothing requires the groups to cover the array.
Examples
>>> groups = array.group_by({"LST": range(1, 5), "MST": range(5, 20)}) >>> groups["LST"].barycenter <Quantity [ 0.895 , 44.8475, 44.925 ] m>
- hyper_fov(min_telescopes=2, rim_points=128, centre=None)¶
Hyper field of view: the sky area covered by the array’s cameras.
Each telescope sees a disc on the sky. Pointing divergently spreads those discs out, trading depth of coverage for width: where several discs overlap a shower is seen by several telescopes and can be reconstructed stereoscopically, where only one covers the sky it cannot. The union of the discs is cut into the patches formed by their boundaries, and each patch is labelled with its multiplicity – how many telescopes see it.
The geometry is done on the sphere, via a Lambert azimuthal equal-area projection centred on the array’s mean pointing. That projection preserves area exactly, so the patch areas are true solid angles, and it has no pole – working directly in the (azimuth, altitude) plane instead breaks down near the zenith, where telescopes a fraction of a degree apart on the sky are hundreds of degrees apart in azimuth.
The projection’s own x and y axes are azimuth and altitude: x is offset in azimuth (increasing to the right, as azimuth increases north-to-east), y is offset in altitude (increasing upward). That holds everywhere the array can point, not just near the centre – the sky map looks the way the telescopes actually move.
- Parameters:
min_telescopes (int) – only count patches seen by at least this many telescopes. The default, 2, is the smallest multiplicity a shower can be reconstructed stereoscopically from – sky seen by a single telescope doesn’t give a stereo reconstruction, so it isn’t counted. Pass 1 to measure the whole covered area instead.
rim_points (int) – number of points sampled around each camera’s rim. The default is accurate to about 0.01% of a disc’s area.
centre (array-like, optional) – unit vector to centre the projection on. The mean pointing by default, which is the right choice when the array is looking at one thing. Pass a direction when the patches have to line up with something else drawn in its own projection – a sky map centred on a localization, say – since two equal-area projections about different points do not share coordinates, and overlaying one on the other without saying so puts the telescopes in the wrong place.
- Returns:
area (astropy.Quantity) – covered area in deg**2, counting only patches at or above min_telescopes
patches (list of (shapely.Polygon, int)) – each patch and the number of telescopes seeing it. x is degrees of offset in azimuth from the array’s mean pointing, y degrees of offset in altitude, both in the equal-area projection, so polygon areas are in deg**2.
- property mean_pointing¶
Direction the array was last asked to diverge from
- Returns:
(alt, az)
- Return type:
tuple of astropy.Quantity, in degrees
- Raises:
ValueError – if the array has never been pointed
- multiplicity_moments(patches=None)¶
Mean and variance of the multiplicity, weighted by area.
One number for how divergent a configuration is, and one for how evenly. Over the patches hyper_fov cuts the sky into, with multiplicity m_i and area A_i,
<m> = sum(m_i A_i) / sum(A_i) Var(m) = sum((m_i - <m>)**2 A_i) / sum(A_i)
Weighting is by solid angle, so a patch counts for as much sky as it covers – an unweighted mean over patches would let a sliver of overlap count for as much as the whole field.
A parallel array has every patch seen by every telescope, so the mean is the number of telescopes and the variance is zero; as divergence grows the mean falls towards one. Equivalently, since each disc contributes its area to every patch it covers, the mean is the array’s total camera solid angle divided by the sky it covers.
The average runs over the sky the array sees, not the whole sky – see multiplicity_profile. It also ignores min_telescopes: hyper_fov applies that only to the area it returns, never to the patch list, so patches from a cut run give the same answer as patches from an uncut one.
- Parameters:
patches (list of (shapely.Polygon, int), optional) – the patches from a previous hyper_fov call, to save computing them again; recomputed if not given
- Returns:
(mean, variance)
- Return type:
tuple of float
Examples
>>> array.divergent_pointing(0.05, 70 * u.deg, 180 * u.deg) >>> mean, variance = array.multiplicity_moments() >>> f"{mean:.2f} +- {np.sqrt(variance):.2f}" '1.66 +- 0.81'
- multiplicity_profile(patches=None)¶
How much sky is seen by exactly one telescope, by exactly two, and so on.
hyper_fov reports how much sky the array covers; this reports how well it covers it. Both matter, and divergence trades one for the other.
Only sky the array sees at all appears here. A ring of discs encloses a hole no telescope sees, and hyper_fov drops those patches, so there is no zero-multiplicity entry and the areas sum to the covered area rather than to the whole sky.
- Parameters:
patches (list of (shapely.Polygon, int), optional) – the patches from a previous hyper_fov call, to save computing them again; recomputed if not given
- Returns:
multiplicity (numpy.ndarray of int) – the multiplicities present, in increasing order
area (astropy.Quantity) – sky seen by exactly that many telescopes, in deg**2
Examples
>>> array.divergent_pointing(0.05, 70 * u.deg, 180 * u.deg) >>> multiplicity, area = array.multiplicity_profile() >>> multiplicity array([1, 2, 3, 4, 5]) >>> np.round(area, 1) <Quantity [242. , 131.3, 70.8, 8. , 0.6] deg2>
- property pointing_altaz¶
All telescopes pointing directions (alt, az) as an array
- Returns:
pointing_directions – 2D array: [[alt1,az1], [alt2, az2], …]
- Return type:
astropy.Quantity
- property pointing_vectors¶
all telescopes pointing vectors as an array
- Returns:
pointing_vectors – 2D numpy array
- Return type:
numpy.array
- property positions_array¶
All telescopes positions
- Returns:
positions_array – 2D array of [x, y, z] in metres
- Return type:
astropy.Quantity
- class divtel.telescope.Telescope(x, y, z, focal, camera_radius, tel_id=None)¶
Bases:
objectDescribe a generic telescope
- Parameters:
x (astropy.quantity) – x ground position
y (astropy.quantity) – y ground position
z (astropy.quantity) – z ground position
focal (astropy.quantity)
camera_radius (astropy.quantity)
tel_id (int, optional) – telescope identifier. CTAO numbers telescopes by type – LSTs 1 to 4, MSTs 5 to 14, telescopes added to try out a configuration from 15 on – so the id carries meaning and is worth setting explicitly; Array.group_by selects on it. Left out, ids come from a counter shared by every Telescope ever built, which makes them unique but not meaningful.
- property fov¶
Area of the field of view in rad**2
- property fov_radius¶
Angular radius of the field of view
- Returns:
half-angle subtended by the camera, in radians
- Return type:
astropy.Quantity
- point_to_altaz(alt, az)¶
Point the telescope in a given direction
- Parameters:
alt (astropy.Quantity) – altitude, from the ground towards z
az (astropy.Quantity) – azimuth, clock-wise from x towards y
- point_to_object(target)¶
Point to object.
- Parameters:
target (astropy.Quantity or numpy.array([x, y, z])) – position of the object; a plain array is read as metres
- property pointing_vector¶
Current pointing direction as a unit vector
- Returns:
[x, y, z], unit length, in the ground frame
- Return type:
numpy.array
- property position¶
Ground position of the telescope
- Returns:
[x, y, z] in metres
- Return type:
astropy.Quantity
- property zenith¶
Zenith angle of the current pointing
- Returns:
angle from the vertical, in radians; the complement of alt
- Return type:
astropy.Quantity
divtel.layout¶
Reading an array layout from file. Layouts are ECSV tables that carry their own units, so a camera radius may be given as a length or as an angle on the sky without ambiguity.
Read an array layout from file.
Layouts are ECSV tables – plain text with a small YAML header naming each column and its unit. Carrying the units in the file removes the ambiguity that plain columns of numbers leave behind: a camera radius may be quoted as a length or as an angle on the sky, and the two look identical once the unit is gone. Here the unit decides, so there is no flag to get wrong.
A layout needs the columns x, y, z, focal and camera_radius,
and may carry an id column of telescope identifiers.
- divtel.layout.camera_radius_in_metres(camera_radius, focal)¶
Camera radius as a length, whether it was given as one or as an angle.
A camera of radius r at the focus of a telescope of focal length f subtends a half-angle arctan(r/f) on the sky, so an angular radius converts back with r = f tan(angle). Angles are what instrument papers quote; metres are what the geometry needs.
- Parameters:
camera_radius (astropy.Quantity) – radius of the camera, as a length or as an angle
focal (astropy.Quantity) – focal length
- Returns:
radius as a length
- Return type:
astropy.Quantity
- divtel.layout.load_array(path)¶
Build an Array from a layout file.
- Parameters:
path (str, pathlib.Path or astropy.table.QTable) – an ECSV layout file, or a table already read from one
- Return type:
Array
Notes
Telescope ids come from the file’s
idcolumn when it has one, so that a layout read twice gives the same ids both times and those ids mean what the layout says they mean. Without that column telescopes are numbered 1 to N in file order.Examples
>>> from importlib.resources import files >>> from divtel.layout import load_array >>> array = load_array(files("divtel") / "data" / "cta-north-lapalma-alpha-prod6.ecsv") >>> len(array.telescopes) 13
- divtel.layout.load_table(path)¶
Read a layout file as a table, without building any telescope.
Useful to look a layout over, or to edit it, before turning it into an Array – load_array accepts the table this returns.
- Parameters:
path (str or pathlib.Path)
- Returns:
with the columns of the file, as quantities
- Return type:
astropy.table.QTable
- Raises:
ValueError – if a required column is missing or the table is empty
divtel.region¶
A target that is not a point: a set of sky directions with a weight on each.
Everything in divtel.strategy works on one, and building one from a
published sky map is the only step that needs anything beyond divtel itself.
A weighted region of sky, and what an array sees of it.
A pointing strategy needs a target, and for anything but a point source the target is a region with structure: a gravitational-wave localization is a probability distribution over the sky, a survey field is a patch with edges, a galaxy catalogue is a list of directions with weights on them. SkyRegion is that target reduced to the only two things the geometry needs – a set of unit vectors and a weight on each – and everything in divtel.strategy works on it.
Reducing it that far is what keeps this module free of any sky-map format. Building a region from a published HEALPix localization needs a HEALPix reader and lives in divtel.skymap, behind an optional dependency; once built, nothing downstream knows or cares where the directions came from.
Frames¶
The vectors are unit vectors in whatever frame they were built in, and two frames are used here for two different questions.
from_coords transforms sky coordinates into the array’s horizontal frame at a given time, which answers “what could the array have done that night”. The region sits where the sky actually put it.
from_table and from_lonlat keep the region in its own celestial frame, and place then rotates it rigidly to a chosen altitude and azimuth, which answers “what can an array do with a region of this shape”. The elevation has to be controllable for that question, because an array looking near the horizon is foreshortened and the same divergence buys a different spread at 20 degrees than at 70.
Sizes¶
“How big is it” has three answers and the gap between them is the point. A two-detector localization is a long thin arc, so its area and its length say completely different things: equivalent_radius treats it as a disc, containment_radius gives the cone an array’s fields of view have to span, and max_separation gives the region’s length. For GW170817’s first alert map those are 7.7, 62.1 and 124.3 degrees.
- class divtel.region.SkyRegion(directions: ndarray, weights: ndarray, pixel_area: Quantity | None = None, meta: dict = <factory>)¶
Bases:
objectA set of sky directions with a weight on each.
- Variables:
directions (numpy.ndarray) – shape (n, 3), unit vectors. See the module docstring on which frame.
weights (numpy.ndarray) – shape (n,), the weight of each direction – a probability, for a localization. Not normalised: visible_part cuts directions away and leaves the survivors’ weights alone, so that coverage measured afterwards stays a fraction of the whole original region rather than silently renormalising the abandoned part out of existence.
pixel_area (astropy.Quantity or None) – solid angle each direction stands for, if the region came from a pixelization. Without it area and equivalent_radius are unavailable.
meta (dict) – provenance – which map, which credible level, which circular carried it
- property area¶
Solid angle of the region.
- Returns:
in deg**2
- Return type:
astropy.Quantity
- Raises:
ValueError – if the region carries no pixel area to sum
- property centroid¶
The weight-weighted centre of the region, as a unit vector.
The mean of the direction vectors, renormalized – the direction the region sits in, treated as a whole. For an arc this lands off the arc itself, inside the sphere’s chord, which is correct: it is a centre of direction, not a point of the region.
- containment_radius(iterations=2000, axis=False)¶
The smallest cone that contains the whole region.
This is the number an array has to match: point the array at the cone’s axis, and every telescope has to reach containment_radius off-axis for the array to see the whole region at once. Twice it is the angular width a divergent configuration must span.
Found by moving a trial axis repeatedly a shrinking step towards whichever direction is currently farthest from it – the standard incremental construction for a minimum enclosing ball, run on the unit vectors. It converges from above onto the true minimum, and it needs no derivatives and no scipy. The weighted centroid is a good enough starting point that a couple of thousand steps settle it to well under a hundredth of a degree.
- Parameters:
iterations (int) – steps of the search; more is tighter and slower
axis (bool) – also return the cone’s axis, as a unit vector
- Returns:
the cone’s angular radius, in degrees
- Return type:
astropy.Quantity, or (astropy.Quantity, numpy.ndarray)
Notes
A region can be more than a hemisphere across, and then no cone smaller than the whole sky contains it. The radius simply comes out above 90 degrees, and the caller should read that as “no single pointing reaches this”.
- property coord¶
The region’s directions as an astropy.coordinates.SkyCoord, in ICRS.
Only meaningful for a region still in celestial coordinates; a placed region’s vectors are directions over the array, and reading them as right ascension and declination is nonsense.
- covered(array, m_cut=2)¶
The weight of the region seen by at least m_cut telescopes.
The m_cut is the whole reason spreading an array is a trade rather than a free win. A shower seen by one telescope cannot be reconstructed stereoscopically, so
m_cut=2is what a real analysis needs, and it is exactly what spreading eats into.- Parameters:
array (divtel.telescope.Array) – already pointed
m_cut (int)
- Returns:
as a fraction of the region’s total weight
- Return type:
float
- describe_extent()¶
Every size measure at once.
- Returns:
area,equivalent_radius,containment_radius,max_separationandelongation– the last being the containment radius in units of the equivalent radius, so 1 is a disc and anything much above it is a stretched region that costs more to cover than its area suggests.- Return type:
dict
- directions: ndarray¶
- property equivalent_radius¶
The radius the region would have if it were a disc.
The naive size, quoted for contrast with containment_radius.
- Returns:
in degrees
- Return type:
astropy.Quantity
- classmethod from_coords(coords, weights, observation=None, pixel_area=None, meta=None)¶
A region from sky coordinates, optionally put over the array.
- Parameters:
coords (astropy.coordinates.SkyCoord)
weights (array-like)
observation (divtel.observation.Observation, optional) – place and time. Given one, the directions come out in the array’s horizontal frame, which is where the region really was that night. Without one they stay in the coordinates’ own frame, ready for place to set them down wherever the question wants them.
pixel_area (astropy.Quantity, optional)
meta (dict, optional)
- Return type:
SkyRegion
- classmethod from_lonlat(lon, lat, weights, pixel_area=None, meta=None)¶
A region from longitudes and latitudes on a sphere.
- Parameters:
lon (astropy.Quantity) – angles, one per direction
lat (astropy.Quantity) – angles, one per direction
weights (array-like)
pixel_area (astropy.Quantity, optional)
meta (dict, optional)
- Return type:
SkyRegion
- classmethod from_table(path)¶
A region read back from an ECSV table of
ra,decandprob.The format divtel.data.gw170817 ships its precomputed credible regions in, so a study – or a notebook running in a browser, where no HEALPix reader is available – can start from a real localization without the sky map it was cut from.
- Parameters:
path (str or pathlib.Path)
- Returns:
in the table’s own celestial frame; call place to set it down
- Return type:
SkyRegion
- property max_separation¶
The greatest angular distance between two directions of the region.
The region’s length, as opposed to its width. For the arc-shaped maps an early two-detector alert produces this runs several times the equivalent radius, and that gap is the whole reason non-parallel pointing is worth considering.
- Returns:
in degrees
- Return type:
astropy.Quantity
Notes
Exact up to twenty thousand directions; above that an evenly-spaced subsample stands in, which can only underestimate, and by less than the spacing between directions.
- meta: dict¶
- multiplicity(array)¶
How many of the array’s cameras cover each direction.
A direction is seen by a telescope when it lies within that telescope’s field-of-view radius of where the telescope points. This is the quantity every strategy in divtel.strategy is spending: a shower seen once cannot be reconstructed stereoscopically, so multiplicity and not coverage is what an array actually delivers at a direction.
- Parameters:
array (divtel.telescope.Array) – already pointed
- Returns:
one entry per direction
- Return type:
numpy.ndarray of int
- pixel_area: Quantity | None = None¶
- place(alt, az)¶
The region set down at a chosen place over the array, shape intact.
For a strategy question a localization supplies a shape: how elongated it is, how its probability is distributed along the arc. Where it happened to be on some night is an accident nobody is planning around. This rotates the region rigidly so its centroid sits at the given altitude and azimuth, leaving every internal angle unchanged.
Controlling the elevation is not cosmetic. An array is foreshortened when it looks near the horizon, so the same divergence produces a different spread and a different multiplicity at 20 degrees than at 70. Comparing regions evaluated at whatever elevation each happened to fall at would mix that in with the effect being measured.
- Parameters:
alt (astropy.Quantity) – where to put the centroid
az (astropy.Quantity) – where to put the centroid
- Returns:
in the array’s ground frame
- Return type:
SkyRegion
Notes
The rotation is the shortest one carrying the centroid to the target, so the region is not spun about its own axis beyond what that implies. For an elongated region the position angle it lands at is therefore arbitrary, which is honest, since the position angle of a future alert is too.
- placement_rotation(alt, az)¶
The rotation that would carry this region’s centroid to a pointing.
Separate from place so that several regions cut from the same map can be moved together. Drawing a picture needs exactly that: the 50, 90 and 99 per cent bands have different centroids, and rotating each by its own would slide them apart instead of nesting them.
- Parameters:
alt (astropy.Quantity)
az (astropy.Quantity)
- Returns:
a 3 by 3 rotation matrix
- Return type:
numpy.ndarray
- rotate(rotation)¶
The same region turned by a rotation matrix.
- Parameters:
rotation (numpy.ndarray) – 3 by 3, as placement_rotation returns
- Return type:
SkyRegion
- to_table(path=None)¶
The region as an ECSV table, the format from_table reads.
- Parameters:
path (str or pathlib.Path, optional) – write it here as well as returning it
- Return type:
astropy.table.Table
- visible_fraction(observation, min_altitude=<Quantity 0. deg>)¶
How much of the region’s weight is above the horizon at a given time.
For a region in celestial coordinates, not a placed one. A localization tens of degrees across sets over an hour or more, so for part of the night the array can only reach part of it however it points. This is the ceiling on what any pointing can cover.
- Parameters:
observation (divtel.observation.Observation)
min_altitude (astropy.Quantity)
- Returns:
between 0 and 1
- Return type:
float
- visible_part(alt_min=<Quantity 0. deg>)¶
The part of a placed region a telescope could actually point at.
place rotates a localization so its centroid sits at a chosen altitude, which says nothing about its ends. GW170817’s first alert map is an arc 124 degrees long; put its centroid at 60 degrees elevation and a sixth of its probability is below the horizon. An optimiser handed that region will cheerfully aim telescopes underground and report the coverage, and every strategy compared against it inherits the fiction.
Cutting the region down first is the fix, and it has to happen before the strategies rather than after: what is left is a different shape, not just a smaller one, and the right pointing for it is different too.
- Parameters:
alt_min (astropy.Quantity) – lowest altitude a telescope may point at. Zero is the horizon and pure geometry; a real observation stops well above it, both because the drives do and because a shower seen through fifteen atmospheres is not worth pointing at.
- Returns:
region (SkyRegion) – the directions above alt_min, with their weights unchanged – so they still sum to the whole region’s weight, and coverage measured against them stays a fraction of the whole
dropped (float) – the weight cut away, as a fraction of the region
- weights: ndarray¶
- divtel.region.as_altaz(vector)¶
A unit pointing vector back as an (alt, az) pair.
The inverse of alt_az_to_vector.
- Parameters:
vector (numpy.ndarray) – [x, y, z], unit length
- Returns:
(alt, az)
- Return type:
tuple of astropy.Quantity, in degrees
- divtel.region.rotation_between(source, target)¶
The shortest rotation carrying one direction onto another.
- Parameters:
source (numpy.ndarray) – unit vectors
target (numpy.ndarray) – unit vectors
- Returns:
a 3 by 3 rotation matrix, to apply as
vectors @ rotation.T- Return type:
numpy.ndarray
divtel.strategy¶
Covering a weighted region: sequential tiling, simultaneous sub-arrays, and shaped pointing that makes the array’s depth follow the weight. GW alerts follow-up: divergence is not the way runs all of them against a real gravitational-wave localization.
Covering a weighted region of sky: tiling, sub-arrays, and shaped pointing.
Divergent pointing spreads an array with one number, and one number buys a
symmetric configuration – a disc, or a ring of discs. Real targets are neither.
A two-detector gravitational-wave localization is an arc a hundred degrees long
and a few degrees wide with the probability piled unevenly along it, and no
value of div makes fifty telescopes look like that. This module holds the
alternatives, all of them working on a divtel.region.SkyRegion.
- tile_region
Keep the array together and visit the region in pieces. Nothing is wasted and every shower is seen by every telescope; what is spent is time, since the window has to be divided between the pointings.
- point_subarrays, point_subarrays_by_type
Split the array into groups and point each group conventionally at a different part of the region, all at once. Every telescope is on target, nothing waits its turn, and each group is an ordinary small array.
- shaped_pointing, shaped_pointing_by_type
Throw the groups away too and point every telescope on its own, so that the array’s depth follows the weight rather than merely covering it.
- weighted_split
The middle road: keep the sub-array framework but deal the sizes in proportion to the weight each group catches. Most of shaped pointing’s gradient, without a solver and without a table of fifty directions.
The rule behind shaped pointing¶
Coverage is not the objective. What the array delivers at a direction is its multiplicity there – how many cameras have it in frame – and that is a continuous resource which can be spent unevenly. Two demands:
see all of the region, not part of it
look hardest where the weight is
Both fall out of one choice. Maximise
J = sum_k w_k log( max(m_k - m_cut + 1, 0) + eps )
over the pointings, where w is the weight of direction k and m its multiplicity. The logarithm is what makes this work, and it is not a fudge:
Its optimum under a fixed budget is m proportional to w. Maximising sum w log m subject to sum m = B gives m_k = B w_k and nothing else. Demand 2 is not imposed, it is what a logarithm returns.
It diverges downward at zero, so leaving any weight unseen costs infinitely much and demand 1 comes free. eps is the finite stand-in, and turning it up is how you buy depth on the core at the price of the tails.
Subtracting m_cut - 1 inside means a direction seen by one telescope is worth exactly what an unseen one is worth. A shower nobody triangulates is not a detection, and an optimiser told otherwise will spread the array into a thin single-telescope sheet that scores beautifully and reconstructs nothing. This is the term that stops it – the same degenerate optimum divergence falls into past its multiplicity ceiling, reached from the other side.
Where the budget runs out¶
Demand 1 is not always satisfiable. The array has a fixed amount of camera to spend – reach measures it, as the multiplicity the array could hold over the region if none of its field of view spilled off the edge – and when that is below m_cut no arrangement covers the whole region stereoscopically. Then the right answer is to give part of it up, and to give up the least weighted part. target_field does that explicitly, by water-filling.
That zero is a decision an observer has to make anyway. Writing it down beats discovering it afterwards in a configuration that covered everything once.
What it costs¶
The configuration is no longer one number. It is a table of alt/az, one row per telescope, which is what divtel.telescope.Array.export_cfg writes. And the pointings are optimised against one weight map at one moment, so a map that is superseded – as gravitational-wave maps always are – invalidates them in a way a divergence value does not.
- divtel.strategy.camera_blur(array, region, chunk=4096)¶
How far the weight map moves when one camera is smeared over it.
A yardstick for the
mismatchin describe, in the same units. It is not a bound and arrangements do beat it – what it measures is the scale of the problem.Multiplicity is a sum of camera discs laid on the sky, so as a field it is the density of pointings smeared by a disc. Structure finer than a camera is therefore not reproducible, and a two-detector arc a few degrees wide against a several-degree camera has plenty of it. Blurring the map with one average camera and measuring how far the result has moved says how much of the mismatch is that limit rather than a failure of the pointing.
A real array beats the single-blur number when it has a mix of cameras – the narrow ones can go where the map is sharp – so treat this as the scale at which matching the map stops being about where the telescopes point and starts being about how big their cameras are.
- Parameters:
array (divtel.telescope.Array) – used for its camera sizes only; it is not re-pointed
region (divtel.region.SkyRegion)
chunk (int) – directions smoothed at a time, so the pairwise comparison never has to exist all at once
- Returns:
divergence from the map to its blurred self, comparable with describe’s
mismatch- Return type:
float
- divtel.strategy.describe(array, region, m_cut=2)¶
What an arrangement delivers over a region.
- Parameters:
array (divtel.telescope.Array) – already pointed, by anything – this measures a divergent configuration, a set of sub-arrays or a shaped one alike
region (divtel.region.SkyRegion)
m_cut (int)
- Returns:
coveredandcovered_stereo, the weight seen by one camera and by m_cut;blind, the weight no camera sees;mean, the weight-weighted mean multiplicity over the whole region;mean_covered, the same average taken over the covered part alone;max; andmismatch.The two means differ only when coverage is incomplete, and then by a lot: an array covering 96 per cent of a region at depth 8.5 has a whole-region mean of 8.2, because the 4 per cent it abandoned enters as zero. Quote
mean_coveredbesidecovered_stereoand neither can flatter the other.- Return type:
dict
Notes
mismatchis the Kullback-Leibler divergence from the weight to the normalised multiplicity, over the part of the region that clears m_cut. It is zero when multiplicity is exactly proportional to weight there, and it is the number shaped_pointing is trying to make small. Read it besidecovered_stereoand never alone: an array that abandons everything but the peak matches the shape of what it kept, perfectly, and is useless.It does not reach zero in practice. Multiplicity is a sum of camera discs, so as a field on the sky it cannot be sharper than one camera, and a region with structure finer than that – which a two-detector arc has, across its width – cannot be matched however the telescopes are placed. camera_blur measures that scale, so read the mismatch against it: the camera is the resolution limit of this whole idea.
- divtel.strategy.multiplicity_by_probability(array, region, bins=5)¶
Mean multiplicity in each fifth of the weight, least likely first.
The plain reading of whether the array is looking hardest where the source probably is. Directions are ordered by weight and cut into bins holding equal total weight, not equal area, so the last bin is the small bright core and the first is the wide faint skirt. A flat list is an array spread evenly over the region; a rising one is the point of shaped_pointing.
- Parameters:
array (divtel.telescope.Array) – already pointed
region (divtel.region.SkyRegion)
bins (int)
- Returns:
mean multiplicity per bin, in order of increasing weight
- Return type:
list of float
- divtel.strategy.point_subarrays(array, region, count, m_cut=2, candidates=4000, seed=0)¶
Split the array and aim each piece at a different part of the region.
The configuration an observer would actually reach for. Divergent pointing spreads one array thin over a patch that is mostly empty sky; tiling covers the region properly but only one piece at a time. Sub-arrays do both at once: each piece is a small ordinary array staring at one part of the region, all of them at the same time, for the whole window.
What it costs is telescopes per shower. Three sub-arrays of seventeen see every shower seventeen times instead of fifty-one, which is still far above the two a stereoscopic reconstruction needs.
Sub-arrays are placed greedily, the one with the widest reach first, each going where it catches the most weight not already covered. Reach is computed per sub-array rather than assumed: dealt thinly enough, a sub-array runs out of wide cameras and its reach collapses to a narrow one, which is a real limit on how far this can be pushed.
- Parameters:
array (divtel.telescope.Array)
region (divtel.region.SkyRegion) – placed over the array
count (int) – how many sub-arrays to make
m_cut (int) – telescopes within one sub-array that must see a direction
candidates (int) – pointings considered, drawn from the region’s own directions
seed (int)
- Returns:
covered(fraction of the region seen by m_cut telescopes or more, counting the whole array),multiplicity(weighted mean over the covered part),on_target(telescopes with the region in frame),sizesandpointings. The array is left pointed.- Return type:
dict
- divtel.strategy.point_subarrays_by_type(array, region, target=0.9, m_cut=2, candidates=4000, seed=0)¶
Split each telescope type on its own, and let each cover the region by itself.
point_subarrays deals telescopes into balanced sub-arrays precisely to stop a mixed sub-array reaching only as far as its narrowest telescope. The alternative is not to balance the mix but to drop it: two telescope types are different instruments, and there is no reason the number of pieces that suits one has to suit the other. Fourteen MSTs might split three ways to reach the region; thirty-seven SSTs, ten times the camera, might need only two. Solving that as one array with one sub-array count forces a compromise neither type asked for.
So each type is handed the whole problem separately: split by field-of-view radius, and within each type scan sub-array counts from one upward until target coverage is reached or the type runs out of telescopes to add. A type with fewer than m_cut telescopes cannot see anything stereoscopically on its own and is left pointed nowhere in particular.
- Parameters:
array (divtel.telescope.Array)
region (divtel.region.SkyRegion)
target (float) – coverage each type’s scan aims for before it stops adding sub-arrays
m_cut (int)
candidates (int)
seed (int)
- Returns:
types, one entry per field-of-view radius holding that type’s chosen point_subarrays result (orNoneif the type has fewer than m_cut telescopes);coveredandmultiplicity, measured on the whole array once every type is pointed, so a direction two types both reach counts once at its true combined multiplicity rather than twice.- Return type:
dict
- divtel.strategy.reach(array, region_area)¶
The multiplicity the array could hold over a region, if nothing spilled.
Total camera solid angle divided by the region’s, so it is the mean multiplicity of a perfect arrangement: every camera entirely on the region and the overlaps distributed exactly as asked. Nothing reaches it – a disc laid on the edge of a region hangs over the side, and the loss grows with the camera – but it is an upper bound that costs one division to compute, and it is the number to look at before anything else.
Below 1 the region cannot be covered at all. Below m_cut it cannot be covered stereoscopically, and part of it has to be abandoned whatever the strategy. shaped_pointing reports the fraction actually achieved as
efficiency.- Parameters:
array (divtel.telescope.Array)
region_area (astropy.Quantity) – solid angle of the region, as divtel.region.SkyRegion.area gives it
- Returns:
mean multiplicity available, dimensionless
- Return type:
float
- divtel.strategy.shaped_pointing(array, region, region_area=None, beta=1.0, m_cut=2, eps=0.1, candidates=1500, search_pixels=2500, sweeps=12, rounds=3, warm_starts=(2, 3, 4, 6, 8, 10, 14), seed=0)¶
Point each telescope where it does the most good, and leave it there.
The solver behind the rule in this module’s docstring. It maximises
sum w log(max(m - m_cut + 1, 0) + eps)over the pointings by coordinate ascent: take one telescope out of the arrangement, work out what the sky looks like without it, and put it back wherever it now helps most. Sweep until nothing wants to move.That inner step is one matrix-vector product. Precompute which directions each candidate pointing would cover – there are only two camera sizes at either CTAO site, so two matrices – and the marginal gain of every candidate for one telescope is that matrix against the per-direction value of one more telescope. Fifty telescopes times a dozen sweeps is a few thousand of them.
Every move is scored on the exact objective and only taken if it improves, so the sweeps climb monotonically and stop. Where they stop depends on where they started, which is why several starts are tried: a greedy build, and the sub-array configurations from point_subarrays, which are good arrangements arrived at by a different argument and hard for a local search to find on its own.
The budget is not known in advance. reach assumes no camera hangs off the edge of the region and every real arrangement loses something that way, by a factor of two on a compact region and rather more on a thin arc. Trusting the geometric value asks for a floor the array cannot hold, and the answer is a region covered once over rather than half of it covered properly. So the budget is calibrated: solve, measure the multiplicity that actually landed, rebuild the target from that, solve again.
- Parameters:
array (divtel.telescope.Array) – left pointed at the answer
region (divtel.region.SkyRegion) – placed over the array
region_area (astropy.Quantity, optional) – solid angle of that region, for the initial budget. Taken from the region itself when it knows its own pixel area.
beta (float) – how hard to follow the weight; see target_field
m_cut (int) – telescopes that must see a direction for it to be worth anything
eps (float) – value of an unseen direction, as
log(eps). Small insists on covering the tails before deepening the core; large trades the tails away. The default sits nearer the coverage end.candidates (int) – pointings considered per telescope, drawn from the region’s own directions. The candidate set is the resolution limit on where a telescope can be put, and at HEALPix nside 256 the pixels are 0.2 degrees apart against cameras of several degrees, so this is not the binding approximation.
search_pixels (int) – directions the sweeps score against. The result is rescored against the whole region before it is returned, so the numbers that come back are exact whatever this is.
sweeps (int) – most passes over the telescopes per start
rounds (int) – budget calibrations
warm_starts (tuple of int) – sub-array counts to try as starting arrangements, alongside the greedy build. Cheap and worth having: a split array is a good arrangement arrived at by a different argument, and in the budget-limited case – where there is not enough camera to cover the region twice over and the answer is a patchwork rather than a gradient – it is the start the sweeps most often end up improving on. Empty to skip them, which is faster and sometimes worse.
seed (int) – for the subsamples, so a rerun gives the same answer
- Returns:
budget(mean multiplicity the arrangement puts on the region, the calibrated figure),reach(the geometric bound),efficiency(their ratio),pointings(list of alt/az, one per telescope, in array order) andobjective. The metrics live in describe.- Return type:
dict
- divtel.strategy.shaped_pointing_by_type(array, region, region_area=None, beta=1.0, m_cut=2, **kwargs)¶
Shape each telescope type’s own coverage, rather than the array’s as one.
shaped_pointing puts every telescope into one coordinate ascent with one shared objective, which lets a narrow-camera telescope and a wide one trade places freely – and an SST and an MST are not fungible that way, any more than a sub-array is well served by mixing them. This solves the same objective once per telescope type instead, each with only its own cameras to place.
region_area is passed unscaled to every type’s solve. reach is camera solid angle divided by region area, so it is already per-type once each type’s own camera radii go in; dividing the region between the types first would be answering a different question, since it is the same region every type is being asked to cover, not a slice of it.
- Parameters:
array (divtel.telescope.Array) – left pointed at the combined answer
region (divtel.region.SkyRegion)
region_area (astropy.Quantity, optional)
beta (as shaped_pointing)
m_cut (as shaped_pointing)
**kwargs – passed through to shaped_pointing for every type
- Returns:
types, one entry per field-of-view radius holding that type’s own shaped_pointing result; everything describe reports, measured on the whole array once every type is pointed.- Return type:
dict
- divtel.strategy.split_into_subarrays(array, count)¶
Deal an array into sub-arrays of even size and even reach.
Called on one telescope type at a time by point_subarrays_by_type, which is the normal path: an MST and an SST are different instruments with different fields of view, and mixing them in one sub-array was the earlier version of this idea. A sub-array that happened to collect only the narrower type reached less far than one that collected the wider, and the sub-array with the narrowest reach set what the whole plan could do.
Nothing here actually requires a single type, though. Telescopes are sorted by field-of-view radius and dealt round-robin, the way cards are dealt, so every sub-array ends up the same size and the same mix of whatever radii went in. Feed it a mixed array and that mix balances the reach; feed it one type and the sorting has nothing left to do.
- Parameters:
array (divtel.telescope.Array)
count (int) – how many sub-arrays to make
- Returns:
named
"1","2"and so on. The sub-arrays hold the same Telescope objects as the parent, so pointing one points the parent’s telescopes too, and multiplicity can be measured on the whole array.- Return type:
dict of str to divtel.telescope.Array
- Raises:
ValueError – if there are not enough telescopes to fill every sub-array
- divtel.strategy.stereo_radius(array, m_cut=2)¶
The angular radius a normally-pointed array covers deeply enough to use.
Pointed normally every telescope looks the same way, so a direction is seen by whichever cameras are wide enough to reach it. Sorting the cameras by radius, a direction is seen by m_cut or more of them exactly when it lies within the m_cut-th widest.
- Parameters:
array (divtel.telescope.Array)
m_cut (int)
- Returns:
angular radius
- Return type:
astropy.Quantity
- Raises:
ValueError – if the array has fewer than m_cut telescopes
- divtel.strategy.target_field(weights, budget, beta=1.0, m_cut=2)¶
The multiplicity the strategy asks for at each direction.
Water-filling. The most weighted directions are served first, each at m_cut, until the budget is spent; whatever remains is poured on top in proportion to weight. Directions never reached are written as zero, and that zero is the strategy saying which part of the region it is giving up on.
With a generous budget the floor is a rounding detail and the field is essentially budget * weights: the proportional allocation the logarithmic objective wants. With a tight one the floor is everything and the field is flat over whatever fraction can be seen in stereo at all.
- Parameters:
weights (numpy.ndarray) – weight per direction; need not be normalised
budget (float) – total multiplicity to spend, in per-direction units – reach times the number of directions. shaped_pointing re-estimates this from what it achieves rather than trusting the geometric value.
beta (float) – how sharply to follow the weight once the floor is paid. 1 tracks it, which is the point of this module. 0 spreads the surplus evenly and asks only for coverage. Above 1 concentrates harder on the core than the weight warrants, which is the right call only if sensitivity rises faster than linearly with multiplicity.
m_cut (int) – multiplicity below which a direction is worth nothing
- Returns:
target multiplicity per direction, summing to budget unless the floor alone exhausts it
- Return type:
numpy.ndarray
- divtel.strategy.tile_region(array, region, pointings=10, m_cut=2, candidates=4000, seed=0)¶
Cover a region with a sequence of ordinary pointings.
The alternative to spreading the array: leave it pointed normally and move it, taking the region in pieces. Each pointing puts every telescope on the same patch, so nothing is wasted on empty sky and the multiplicity stays at its maximum. What is spent instead is time, since the observing window has to be divided between the pointings.
Tiles are chosen greedily. The first goes where it catches the most weight, and each one after it where it catches the most of what is still uncovered. Greedy disc covering is not optimal, but it is within a small constant factor of optimal and it matches how an observer would actually work down a region.
- Parameters:
array (divtel.telescope.Array) – used only for its camera sizes; it is not re-pointed
region (divtel.region.SkyRegion) – placed over the array
pointings (int) – how many tiles to plan
m_cut (int) – telescopes that must see a direction for it to count
candidates (int) – tile centres considered, drawn from the region’s own directions
seed (int) – for the subsample, so a rerun gives the same tiling
- Returns:
one per tile in order, with
alt,az,new(weight this tile adds, as a fraction of the region) andcumulative- Return type:
list of dict
- divtel.strategy.weighted_split(array, region, count, m_cut=2, candidates=4000, seed=0)¶
Sub-arrays whose sizes follow the weight each of them catches.
split_into_subarrays deals round-robin, so every group holds the same number of telescopes; the multiplicity inside a group’s footprint is that number, and the depth profile is therefore flat by construction rather than by any property of tiling. A gradient needs unequal groups.
This builds one. An even split is placed first, to fix where the groups go and how much weight each catches; the telescopes are then re-dealt so that each group’s size is proportional to its catch, subject to every group keeping at least m_cut; and the groups are re-pointed at the same places.
Holding the placement fixed means the answer is a lower bound on what unequal splits can do – a joint optimisation over placement and size would do at least as well. Even so it recovers most of shaped_pointing’s gradient without a solver and without leaving the sub-array framework, which is a change to a scheduling heuristic rather than a new method.
- Parameters:
array (divtel.telescope.Array)
region (divtel.region.SkyRegion)
count (int) – how many sub-arrays to make
m_cut (int)
candidates (int)
seed (int)
- Returns:
as point_subarrays, with
sizesnow unequal- Return type:
dict
- Raises:
ValueError – if the array cannot give every group its m_cut floor
divtel.pointing¶
The pointing geometry underneath
divergent_pointing(). Most users won’t need to
call these directly. They define the coordinate frame and the point G
that divergence is built around; see What div actually means.
Functions to define telescopes pointings We use the same reference frame as simtel_array: X is pointing North Y is pointing West Z is pointing upward Az is taken from X (North) towards East, and between -180 and 180 degrees Alt is taken from ground (towards Z) and between -90 and 90 degrees
East is -Y, so a pointing vector is (cos(alt)cos(az), -cos(alt)sin(az), sin(alt)). This is astropy’s azimuth convention too, which is what lets divtel.observation hand alt/az straight to Array.divergent_pointing with no conversion in between.
- divtel.pointing.alt_az_to_vector(alt, az)¶
Compute a pointing vector coordinates (x,y,z) from an alt,az pointing direction
- Parameters:
alt (float) – angle in rad
az (float) – angle in rad
- Returns:
vector – [x, y, z]
- Return type:
numpy.array
- divtel.pointing.as_altaz(vector)¶
A unit pointing vector back as an (alt, az) pair.
The inverse of alt_az_to_vector.
- Parameters:
vector (numpy.ndarray) – [x, y, z], unit length
- Returns:
(alt, az)
- Return type:
tuple of astropy.Quantity, in degrees
- divtel.pointing.best_pointing(array, region, div=0.0, m_cut=2, rings=6, spokes=12, refine=6, search_pixels=2000, seed=0, start=None)¶
Where to point, at a given divergence, to cover as much weight as possible.
An array cannot always contain a region, and when it cannot, where it points decides how much it gets – so the pointing is searched rather than assumed. Neither obvious guess survives contact with a real alert: for GW170817’s first map the weighted centroid of the arc lies off the arc, and the axis of the smallest enclosing cone lies 37 degrees from the source.
The candidates are a polar grid on the sky around the region’s centroid, reaching out to the region’s own extent plus one field of view. That bound is deliberate and it is not a performance compromise. Left unbounded, the search stops answering the question: a widely diverged array covers a ring rather than a disc, so aiming it sixty degrees off the region can land part of that ring squarely on it and score full marks, while every telescope not on the ring stares at empty sky. That configuration maximises the number this function returns and is worthless – it is a handful of telescopes on the target and forty pointed nowhere, which is not divergent pointing but a badly chosen sub-array. Confining the aim to the region keeps “covered weight” a measure of how well the array is used, not of how cleverly it can be positioned to game a metric.
- Parameters:
array (divtel.telescope.Array)
region (divtel.region.SkyRegion) – placed over the array
div (float) – divergence to search at; the answer depends on it, since a widely spread configuration wants aiming differently from a tight one
m_cut (int)
rings (int) – the polar grid: rings distances out to the array’s reach, spokes directions around each
spokes (int) – the polar grid: rings distances out to the array’s reach, spokes directions around each
refine (int) – rounds of local search after the grid, each halving the step
search_pixels (int) – directions the search itself scores against. The grid is swept hundreds of times over, and ranking candidates does not need every one – the winner is rescored against the whole region before being returned, so the number that comes back is exact whatever this is.
seed (int) – for the subsample, so a rerun gives the same answer
start ((astropy.Quantity, astropy.Quantity), optional) – an extra (alt, az) candidate to seed the search with. The best pointing moves smoothly with divergence, so handing a scan’s previous answer back in costs one evaluation and stops neighbouring points on the curve landing in different local optima – which shows up as a curve that jumps about rather than as an obviously wrong answer.
- Returns:
alt,az,covered; the array is left pointed at the best one- Return type:
dict
- divtel.pointing.div_for_half_angle(array, half_angle, alt, az, tolerance=0.0001)¶
The divergence that spreads an array’s pointings over a given half-angle.
The direct answer to “the region is this big, how far do I have to diverge”. A divergent pointing puts a point G a distance
norm = 100 m / tan(arcsin(div))
behind the array along the mean pointing, then aims every telescope along the line from G through itself. A telescope lying a perpendicular distance
dfrom the array’s axis and a distancesalong it therefore ends uparctan(d / (norm + s))
off the mean pointing, and the array’s spread is the largest of those. That is monotone decreasing in norm, so the norm putting the widest telescope exactly at half_angle is found by bisection, and
div = sin(arctan(100 m / norm))
puts G there.
Both distances are measured against the pointing direction rather than against the ground, so an array seen edge-on at low elevation is correctly foreshortened and the same request costs more divergence than it would at the zenith. Keeping the along-axis term matters: dropping it, as a small-angle treatment does, understates the divergence needed by a third by the time the spread reaches 40 degrees, because the telescopes on the far side of the barycenter sit closer to G than the perpendicular distance alone suggests.
- Parameters:
array (divtel.telescope.Array)
half_angle (astropy.Quantity) – angle the outermost telescope should sit at, off the mean pointing
alt (astropy.Quantity) – the mean pointing the array will diverge from
az (astropy.Quantity) – the mean pointing the array will diverge from
tolerance (float) – convergence on div
- Returns:
the div parameter, clipped into [0, 1]
- Return type:
float
Notes
Exact for the geometry, but it answers a question about the array’s widest telescope, and it measures angles from the requested pointing while pointing_spread measures them from the mean of the pointings the array ends up with – G lies behind the array, so the two differ slightly. Read the result back with pointing_spread. solve_div sidesteps all of this by searching on the covered weight itself.
- divtel.pointing.div_for_multiplicity(array, alt, az, target=2.0, div_max=1.0, tolerance=0.001)¶
The most an array can be spread before showers stop being seen twice.
The ceiling on divergence, and it has nothing to do with the target. Spreading trades depth for width, and the trade has a hard floor: a shower seen by one telescope cannot be reconstructed stereoscopically, so once the mean multiplicity falls to two the array is spent. Past that point further divergence covers more sky with telescopes that can no longer do anything with what they see.
This matters most for a small array. Thirteen telescopes asked to span sixty degrees end up with a mean multiplicity of one and cover nothing usefully, even though the geometry did exactly what was requested – so a strategy that sizes divergence from the region alone will happily recommend a configuration that returns nothing.
Mean multiplicity falls monotonically with divergence, so a bisection finds the crossing.
- Parameters:
array (divtel.telescope.Array)
alt (astropy.Quantity) – the pointing to evaluate at; the answer is mildly elevation-dependent, through the same foreshortening div_for_half_angle accounts for
az (astropy.Quantity) – the pointing to evaluate at; the answer is mildly elevation-dependent, through the same foreshortening div_for_half_angle accounts for
target (float) – multiplicity to hold, area-weighted over the sky the array sees. Two is the stereoscopic floor.
div_max (float) – largest divergence considered
tolerance (float) – convergence on div
- Returns:
the divergence at which the mean multiplicity reaches target, or div_max if it never falls that far
- Return type:
float
Notes
Uses divtel.telescope.Array.multiplicity_moments, which averages over the sky the array covers rather than over any region – the ceiling is a property of the configuration, not of what it happens to be pointed at.
- divtel.pointing.div_scan(array, region, divs, alt=None, az=None, m_cuts=(1, 2), hyper_fov=True, optimize_pointing=False)¶
Coverage, field of view and multiplicity across a range of divergences.
The curve the divergence trade turns on: as div grows the array covers more of the region, and fewer telescopes see any part of it.
- Parameters:
array (divtel.telescope.Array)
region (divtel.region.SkyRegion) – placed over the array
divs (iterable of float) – divergences to try
alt (astropy.Quantity, optional) – mean pointing to diverge from. Defaults to the region’s centroid, which is where an observer with no better idea would point.
az (astropy.Quantity, optional) – mean pointing to diverge from. Defaults to the region’s centroid, which is where an observer with no better idea would point.
m_cuts (iterable of int) – multiplicity cuts to report coverage for
hyper_fov (bool) – also compute the array’s total covered solid angle and multiplicity moments. Accurate but slow – it is the polygon arrangement in divtel.telescope.Array.hyper_fov – so it can be turned off for a fine scan.
optimize_pointing (bool) – re-choose the pointing at each divergence with best_pointing, rather than holding alt and az fixed. What a real observer would do, and it matters for a region the array cannot contain: the best place to point a tightly-packed array is not the best place to point a spread one.
- Returns:
one per divergence, with
div,alt,az,spread,covered(a dict keyed by m_cut), and when askedhyper_fov_area,stereo_area,multiplicity_meanandmultiplicity_std- Return type:
list of dict
- divtel.pointing.local_frame(alt, az)¶
Unit tangent vectors at an alt/az pointing, towards increasing az and alt.
These are the directions an observer standing at that pointing and looking up would call “sideways” (towards increasing azimuth) and “up” (towards increasing altitude). They are exact, not approximations valid only near the pointing: alt/az is an orthogonal coordinate system on the sphere, so the two are always unit length and always perpendicular to each other and to alt_az_to_vector(alt, az), at every pointing.
Used to orient a small flat map – a camera’s field of view, or the sky map Array.hyper_fov builds – around a pointing, so that moving along one returned vector reads as “more altitude” and the other as “more azimuth”, whatever the pointing itself is.
- Parameters:
alt (astropy.Quantity)
az (astropy.Quantity)
- Returns:
(increasing_az, increasing_alt) – unit vectors [x, y, z]
- Return type:
tuple of numpy.array
- divtel.pointing.pointG_position(barycenter, div, alt_mean, az_mean)¶
Compute the position of g_point for the pointing
- Parameters:
barycenter (astropy.Quantity or np.array([x,y,z])) – position of the barycenter of the array; a plain array is read as metres
div (float)
alt_mean (astropy.Quantity) – mean pointing altitude in radians from which to diverge
az_mean (astropy.Quantity) – mean pointing azimuth in radians from which to diverge
- Return type:
astropy.Quantity [Gx, Gy, Gz], in metres
- divtel.pointing.pointing_spread(array)¶
How far off the mean the array’s telescopes are pointing.
The observable side of the divergence parameter. div is a number between 0 and 1 whose meaning is tied to a hundred metres of baseline; this is the angle on the sky it produces for the array actually in hand, which is what can be compared against the size of a target region.
- Parameters:
array (divtel.telescope.Array) – already pointed
- Returns:
mean,maxandrmsoffset from the mean pointing direction, as astropy.Quantity angles- Return type:
dict
- divtel.pointing.solve_div(array, region, alt=None, az=None, target=0.9, m_cut=2, coarse=41, tolerance=0.001, optimize_pointing=False)¶
The least divergence that covers a given fraction of the region.
Searched rather than derived. A coarse sweep of the whole range first, then bisection inside the bracket it finds: coverage rises with divergence at first and can fall again once the fields of view pull apart and the multiplicity cut starts biting, so bisecting from the outset could converge onto the wrong side of a maximum.
- Parameters:
array (divtel.telescope.Array)
region (divtel.region.SkyRegion) – placed over the array
alt (astropy.Quantity, optional) – mean pointing to diverge from; the region’s centroid by default
az (astropy.Quantity, optional) – mean pointing to diverge from; the region’s centroid by default
target (float) – fraction of the region’s weight to cover
m_cut (int)
coarse (int) – points in the initial sweep of div over [0, 1]
tolerance (float) – stop when the bracket on div is this narrow
optimize_pointing (bool) – re-aim at every divergence with best_pointing
- Returns:
divandcoveredat the solution,spreadthere, andbest_div/best_covered– the most the array can cover at all.divis None when the target is out of reach, and then the best entries say how close it gets and where.- Return type:
dict
- divtel.pointing.spread_scan(array, divs, alt, az)¶
How divergence trades depth for width, with no region involved.
div_scan folds a target in at every step, because covered weight is what a strategy is chasing. But two of the numbers it reports along the way – mean multiplicity and the hyper field of view’s area – do not need a target to mean something. They describe the array and the divergence alone.
The two move in lockstep, and by a fixed amount. Multiplicity is a sum of camera discs, so
<m> = total camera solid angle / hyper_fov arearegardless of how the array is spread – that identity is what Array.multiplicity_moments computes. Dividing the hyper field of view by the array’s mean single-camera area instead of by its raw solid angle turns that into<m> * n_fov = n_telescopes, a constant: as the array spreads,n_fovgrows from one camera’s worth of sky towards n_telescopes cameras’ worth exactly as fast as <m> falls from n_telescopes towards one. Neither curve carries information the other does not; the point of plotting both is that “one camera wide, seen fifty times over” and “fifty cameras wide, seen once” are the two ends of the same trade, in units an observer can picture.- Parameters:
array (divtel.telescope.Array)
divs (iterable of float) – divergences to try
alt (astropy.Quantity) – mean pointing to diverge from; the hyper field of view is mildly elevation-dependent through the same foreshortening div_for_half_angle accounts for, so pick one pointing and hold it for the whole scan
az (astropy.Quantity) – mean pointing to diverge from; the hyper field of view is mildly elevation-dependent through the same foreshortening div_for_half_angle accounts for, so pick one pointing and hold it for the whole scan
- Returns:
one per divergence, with
div,spread,multiplicity_mean,multiplicity_std,hyper_fov_area(astropy.Quantity, at min_telescopes 1) andn_fov– the hyper field of view measured in cameras, i.e.hyper_fov_areadivided by the array’s mean single-camera solid angle- Return type:
list of dict
- divtel.pointing.tel_div_pointing(tel_position, g_point)¶
Divergent pointing to a point G. Update telescope pointing
- Parameters:
tel_position (astropy.Quantity or np.array([x, y, z])) – telescope coordinates; a plain array is read as metres
g_point (astropy.Quantity or numpy.array([Gx, Gy, Gz]))
- Returns:
(alt, az) – pointing direction, in radians
- Return type:
tuple of astropy.Quantity
divtel.observation¶
Where and when the array is observing from: the optional half that connects
the ground-frame geometry to real sources at real times. Nothing in
divtel.telescope depends on it.
When and where the array is observing from.
The geometry in divtel.telescope knows nothing about the sky. It works in a frame fixed to the ground – x north, y west, z up – and an array pointed at alt 70, az 180 stays pointed there whatever the hour. That is the right model for the geometry, and it is not enough to plan an observation: a source rises and sets, so the direction to point at depends on where you are and what time it is.
This module supplies that missing half, and stays optional. Nothing in divtel.telescope imports it, and an Observation never touches an Array – it converts a sky position into the alt/az pair Array.divergent_pointing already takes:
obs = Observation(site=”north”, time=”2026-03-01T23:00:00”) alt, az = obs.altaz_of(SkyCoord(ra=83.633 * u.deg, dec=22.015 * u.deg)) array.divergent_pointing(0.02, alt, az)
divtel’s azimuth is astropy’s: measured from north through east. No conversion happens anywhere in here, and a test pins that down by pointing an array at a source and reading its right ascension and declination back off the telescopes.
- class divtel.observation.Observation(site='north', time=None)¶
Bases:
objectA place and a time to observe from.
- Parameters:
site (str or astropy.coordinates.EarthLocation) –
"north"or"south"for the CTAO sites; any other name is looked up in astropy’s site registry, which needs network access the first time it is used. An EarthLocation is taken as it is.time (str, astropy.time.Time, optional) – when the observation happens, UTC. Defaults to now.
- Variables:
location (astropy.coordinates.EarthLocation)
time (astropy.time.Time)
Examples
>>> obs = Observation(site="north", time="2026-03-01T23:00:00") >>> crab = SkyCoord(ra=83.633 * u.deg, dec=22.015 * u.deg) >>> alt, az = obs.altaz_of(crab) >>> f"{alt:.2f}, {az:.2f}" '50.93 deg, 270.16 deg'
- after(delta)¶
The same place, later (or, for a negative delta, earlier).
- Parameters:
delta (astropy.Quantity) – elapsed time, e.g.
2 * u.hour- Return type:
Observation
Examples
>>> obs.after(-30 * u.min).time.isot '2026-03-01T22:30:00.000'
- property altaz¶
The horizontal frame at this place and time.
- Return type:
astropy.coordinates.AltAz
- altaz_of(target)¶
Where to point to see a target, in the alt/az Array takes.
- Parameters:
target (astropy.coordinates.SkyCoord or str) – a sky position, or a name to resolve – resolving a name queries a name server, so it needs network access.
- Returns:
(alt, az)
- Return type:
tuple of astropy.Quantity, in degrees
Notes
The azimuth is returned as astropy measures it, from north through east, which is already what Array.divergent_pointing expects.
Examples
>>> alt, az = obs.altaz_of(SkyCoord(ra=83.633 * u.deg, dec=22.015 * u.deg)) >>> array.divergent_pointing(0.02, alt, az)
- at(time)¶
The same place at another time.
Returns a new Observation rather than changing this one, so an array pointed from one cannot quietly fall out of step with it.
- Parameters:
time (str or astropy.time.Time)
- Return type:
Observation
- body(name)¶
Where a solar system body is, in this frame.
- Parameters:
name (str) – as astropy.coordinates.get_body takes it, e.g.
"moon"- Returns:
in the horizontal frame of this observation
- Return type:
astropy.coordinates.SkyCoord
- property moon¶
The Moon, in this frame.
- Return type:
astropy.coordinates.SkyCoord
- property sun¶
The Sun, in this frame – negative altitude means night.
- Return type:
astropy.coordinates.SkyCoord
- class divtel.observation.Window(start: Time, end: Time, duration: Quantity, alt_max: Quantity, alt_start: Quantity, alt_end: Quantity, time_best: Time)¶
Bases:
objectA stretch of time when a target is observable.
- Variables:
- alt_end: Quantity¶
- alt_max: Quantity¶
- alt_start: Quantity¶
- duration: Quantity¶
- end: Time¶
- start: Time¶
- time_best: Time¶
- divtel.observation.altaz_track(target, site, times)¶
Altitude of a target and of the Sun, over a series of times.
- Parameters:
target (astropy.coordinates.SkyCoord)
site (str or astropy.coordinates.EarthLocation) – as Observation takes it, e.g.
"south"times (astropy.time.Time) – an array of times
- Returns:
(target_alt, sun_alt) – in degrees, one entry per time
- Return type:
tuple of astropy.Quantity
- divtel.observation.observable_windows(target, site, start, duration, step=<Quantity 5. min>, min_altitude=<Quantity 0. deg>, sun_altitude=<Quantity -18. deg>)¶
Every stretch in which a target is up and the sky is dark.
A Cherenkov telescope needs a dark sky and a source above the horizon, and a transient alert arrives whenever it arrives. Between the two there is often no overlap at all: GW170817 merged at 12:41 UTC with its localization already low in the western sky, and by the time either CTAO site was dark the region was setting. So before any question about pointing, there is a question about whether there is anything to point at.
- Parameters:
target (astropy.coordinates.SkyCoord)
site (str or astropy.coordinates.EarthLocation)
start (str or astropy.time.Time) – when to start looking
duration (astropy.Quantity) – how far ahead to look, e.g.
5 * u.daystep (astropy.Quantity) – sampling; window edges are resolved to this
min_altitude (astropy.Quantity) – lowest altitude counted as observable. Zero is the geometric horizon, which is generous – an air shower seen through that much atmosphere is far above any useful energy threshold.
sun_altitude (astropy.Quantity) – how far the Sun must be below the horizon
- Returns:
in time order; empty if the target is never observable
- Return type:
[Window]
- divtel.observation.pointing_coord(array, observation, icrs=True)¶
Where each telescope of an array is pointing, on the sky.
The inverse of the Observation.altaz_of trip: alt/az in, sky position out. With divergent pointing the telescopes look in different directions, so this is one coordinate per telescope, and it is what says which part of the sky each one is actually watching.
- Parameters:
array (Array)
observation (Observation)
icrs (bool) – return right ascension and declination; otherwise leave the coordinates in the horizontal frame
- Returns:
one entry per telescope, in the order of
array.telescopes- Return type:
astropy.coordinates.SkyCoord
Examples
>>> alt, az = obs.altaz_of(crab) >>> array.divergent_pointing(0.02, alt, az) >>> pointing_coord(array, obs).separation(crab).to(u.deg).max() <Angle 4.3121837 deg>
divtel.skymap¶
Reading a published HEALPix localization and cutting credible regions out of it. The one part of divtel with a dependency of its own, so it lives behind an extra:
pip install divtel[skymap]
Precomputed GW170817 regions ship in divtel/data/gw170817, so a study that
only wants those needs neither this module nor the extra.
divtel.visualization¶
Plotting helpers. Each takes an optional ax and returns it, so they compose
with whatever else you are drawing.
- class divtel.visualization.Projection(centre, east, north, offset=(0.0, 0.0), turn=None)¶
Bases:
objectA Lambert azimuthal equal-area frame, centred on a region.
Areas on the page are true solid angles – the same projection Array.hyper_fov measures in, chosen for the same reasons: it keeps areas true and it has no pole to fall over near the zenith.
Held as an object rather than recomputed per call so that everything drawn on one panel shares it. That matters as soon as more than the region itself is drawn: credible bands cut at different levels have different centroids, and projecting each in its own frame would slide them apart instead of nesting them, while camera footprints in a frame of their own would land somewhere other than where they point.
Build one with projection_frame and pass it to project_region, project_directions and camera_rims.
- divtel.visualization.camera_rims(array, region=None, ax=None, frame=None, colors=None, labels=None, type_names=None, points=129, linewidth=1.0, alpha=0.75, zorder=6)¶
The outline of every telescope’s field of view, drawn over a region.
The picture behind a coverage number: which parts of the sky the cameras actually sit on, and which parts they miss. Composes with multiplicity_over_region and probability_over_region – pass the same frame to all of them and they land on top of each other.
Each rim is sampled on the sphere and then projected, rather than drawn as a circle on the page. Away from the centre of an equal-area projection the image of a circle is not one, and it is the true shape that shows a camera at the edge of the frame covering what it really covers.
- Parameters:
array (divtel.telescope.Array) – already pointed, by any strategy
region (divtel.region.SkyRegion, optional) – used only to build a frame when none is given
ax (matplotlib.axes.Axes, optional)
frame (Projection, optional) – the frame to draw in; built from region otherwise
colors (list of str or
"type", optional) – one colour per telescope, inarray.telescopesorder."type"colours by camera radius via type_colors, which is usually what is wanted. A single neutral ink otherwise.labels (dict, optional) – colour to legend label. Filled in by
colors="type".type_names (sequence of str, optional) – passed to type_colors when
colors="type", to legend the camera types by name (e.g.("SST", "MST")) rather than by radius.points (int) – samples around each rim
linewidth (float) – passed to the line
alpha (float) – passed to the line
zorder (float) – passed to the line
- Returns:
extent –
(x_min, x_max, y_min, y_max)of the rims on the page, in degrees. Frame the panel on this together with the region’s own extent, or a camera hanging off the edge is silently cropped and the picture overstates the coverage. frame_on does that.- Return type:
tuple of float
- divtel.visualization.display_groups(groups, projection='xy', ax=None, cmap='tab10', annotate=True)¶
Display sub-arrays, each in its own colour with its own barycenter.
Array.display_2d draws the whole array in one colour around one barycenter. On a layout of several telescope types that hides the thing worth seeing: where each type sits, and how far each type’s mean pointing has swung away from the others under divergence.
- Parameters:
groups (dict of str to Array) – as returned by Array.group_by
projection (str) – ‘xy’, ‘xz’ or ‘yz’
ax (matplotlib.pyplot.axes, optional)
cmap (str) – colormap the group colours are taken from
annotate (bool) – label each barycenter with its group name
- Returns:
ax
- Return type:
matplotlib.pyplot.axes
Examples
>>> groups = array.group_by({"LST": range(1, 5), "MST": range(5, 20)}) >>> display_groups(groups)
- divtel.visualization.display_hyper_fov(array, ax=None, min_telescopes=2, cmap='viridis', show_area=True)¶
Display the array’s hyper field of view on the sky.
Each telescope sees a disc; divergent pointing spreads those discs out, widening the sky the array covers at the cost of how many telescopes see any given part of it. This draws the union of the discs, shaded by multiplicity – the number of telescopes seeing each patch. Patches seen by two or more can be reconstructed stereoscopically; patches seen by one cannot.
- Parameters:
array (Array)
ax (matplotlib.pyplot.axes, optional)
min_telescopes (int) – multiplicity counted towards the area reported in the title; patches below it are still drawn, faded. The default, 2, reports the stereoscopic area; pass 1 for the whole covered area.
cmap (str) – colormap used to shade multiplicity
show_area (bool) – title the axes with the covered area
- Returns:
ax
- Return type:
matplotlib.pyplot.axes
Notes
Drawn in an equal-area projection centred on the array’s mean pointing: x is offset in azimuth, y is offset in altitude, and the areas are true solid angles – see Array.hyper_fov.
- divtel.visualization.frame_on(ax, *extents, pad=0.04, equal=True)¶
Set a panel’s limits to hold everything drawn on it.
A region and the cameras pointed at it need framing together: on the region alone a camera at the edge is silently cropped and the picture overstates the coverage, and on the cameras alone a strategy that flings them wide shrinks the region to a smear.
- Parameters:
ax (matplotlib.axes.Axes)
*extents (tuple of float) –
(x_min, x_max, y_min, y_max)boxes, as region_extent and camera_rims return thempad (float) – margin to leave, as a fraction of the box
equal (bool) – keep the aspect equal, so areas on the page stay true areas on the sky
- Return type:
matplotlib.axes.Axes
- divtel.visualization.multiplicity_by_probability(profiles, ax=None, m_cut=2, bins=5)¶
Mean multiplicity in each fifth of the weight, least likely first.
The plain reading of whether an array is looking hardest where the source probably is: a flat set of bars is an array spread evenly over the region, a rising one is divtel.strategy.shaped_pointing doing what it claims.
- Parameters:
profiles (dict of str to list of float) – one entry per strategy, as divtel.strategy.multiplicity_by_probability returns them
ax (matplotlib.axes.Axes, optional)
m_cut (int) – drawn as a floor line – below it a direction is covered but not usable
bins (int) – how many the profiles were cut into, for labelling
- Return type:
matplotlib.axes.Axes
- divtel.visualization.multiplicity_over_region(array, region, ax=None, title=None, vmax=None, radius=None, colorbar=True, align=True, size=4.0, cmap='Blues', blind_color='0.85', frame=None)¶
A weighted region coloured by how many telescopes see each part of it.
The figure that separates the pointing strategies. A parallel array paints one solid blob and leaves the rest blank; a diverged one paints a wide ring, evenly; a shaped one paints the region’s own shape, darkest where the weight is. Whether it does is something to look at rather than take on trust from a single number.
Both the region and the colours are in the equal-area projection of project_region, so a wide patch of low multiplicity looks as big as it is.
- Parameters:
array (divtel.telescope.Array) – already pointed, by any strategy
region (divtel.region.SkyRegion) – placed over the array
ax (matplotlib.axes.Axes, optional)
title (str, optional)
vmax (int, optional) – top of the colour scale, above which the colour stops changing. Set it by hand across a row of panels, or each is normalised to its own maximum and they cannot be compared. A parallel array piles every telescope on one spot and its maximum is the whole array, so scaling a row to that leaves every other panel a uniform pale wash: pick a high percentile of the row instead and let the parallel panel clip.
radius (astropy.Quantity, optional) – half-width of the plot; sized to hold the region by default
colorbar (bool) – draw one beside this panel. Turn it off for a row of panels sharing a scale and add a single bar for the figure.
align (bool) – turn the region so its long axis lies along the page. A region that runs diagonally fills a square panel with empty corners and packs badly beside other panels; laid flat the same region is wide and short. The axis labels stop being azimuth and altitude when this is on, which is why it can be turned off – for a compact region there is nothing to align and the true orientation is worth more. Ignored when frame is given.
size (float) – marker area for the directions
cmap (str) – one hue, light to dark: multiplicity is a magnitude, not an identity
blind_color (str) – colour for directions no telescope sees
frame (Projection, optional) – draw in this frame rather than the region’s own, so this panel lands on top of whatever else was drawn in it – camera_rims, say
- Returns:
the drawn points, to hang a shared colour bar on
- Return type:
matplotlib.collections.PathCollection
Notes
Directions no telescope sees are drawn in grey rather than left off the page. Where an array is blind is most of the point of the picture, and a gap reads as “no region here” instead of “no telescopes here”.
- divtel.visualization.multiplicity_plot(array, min_telescopes=2, ax=None, cmap='viridis')¶
Bar chart of how much sky is seen by how many telescopes.
display_hyper_fov shades the sky map by multiplicity, which shows where the well-covered parts are; this counts them up, which shows how much there is of each. Bars are coloured to match that map, so the two read together.
- Parameters:
array (Array)
min_telescopes (int) – multiplicity counted towards the area reported in the title; bars below it are still drawn, faded. The default, 2, reports the stereoscopic area; pass 1 for the whole covered area.
ax (matplotlib.pyplot.axes, optional)
cmap (str) – colormap used to shade multiplicity, as in display_hyper_fov
- Returns:
ax
- Return type:
matplotlib.pyplot.axes
Examples
>>> array.divergent_pointing(0.05, 70 * u.deg, 180 * u.deg) >>> multiplicity_plot(array)
- divtel.visualization.probability_over_region(region, bands=None, ax=None, frame=None, color='#2a78d6', size=4.0, align=True)¶
A region shaded by where the source probably is.
The companion to multiplicity_over_region: that one shows what the array delivers, this one shows what it is being asked to cover. Draw the two side by side, or draw camera_rims over this one to see which parts of the probability the cameras sit on.
Shaded by credible level, never by raw probability density. Density spans orders of magnitude and plots as a thin bright thread with nothing around it; the credible level answers the question actually being asked, which is where the source is likely to be and how confident that is.
With no bands the level is computed from the region’s own weights, the same greedy construction that cut it: take the most probable direction, then the next, and shade each by the probability accumulated when it was taken. That gives a continuous ramp rather than a few discrete rings, and it needs nothing but the region.
- Parameters:
region (divtel.region.SkyRegion) – placed over the array. Sets the frame, and is what gets shaded when bands is None.
bands (dict of float to divtel.region.SkyRegion, optional) – nested credible regions, keyed by level, drawn as discrete rings instead. Place them with one shared rotation – divtel.region.SkyRegion.placement_rotation and rotate – or they will not nest.
ax (matplotlib.axes.Axes, optional)
frame (Projection, optional)
color (str) – the hue the bands are shaded in, light to dark
size (float) – marker area
align (bool) – see projection_frame; ignored when frame is given
- Returns:
frame – the frame everything was drawn in, to pass to camera_rims
- Return type:
Projection
- divtel.visualization.project_directions(directions, frame)¶
Arbitrary unit vectors on the page, in a region’s frame.
- Parameters:
directions (numpy.ndarray) – shape (n, 3)
frame (Projection)
- Returns:
x, y
- Return type:
numpy.ndarray
- divtel.visualization.project_region(region, align=True, frame=None)¶
A placed region as x and y on the page, in degrees.
Shared rather than inlined because a figure’s size has to be chosen from the same projection its contents are drawn in, and the two drifting apart is not a visible failure – it is a panel that is quietly the wrong shape.
- Parameters:
region (divtel.region.SkyRegion) – placed over the array
align (bool) – see projection_frame; ignored when frame is given
frame (Projection, optional) – draw in this frame instead of the region’s own. Pass one region’s frame when projecting another – a narrower credible band, say – so the two land on top of each other.
- Returns:
x, y – degrees from the frame’s centre
- Return type:
numpy.ndarray
- divtel.visualization.projection_frame(region, align=True)¶
The equal-area frame a region is drawn in.
- Parameters:
region (divtel.region.SkyRegion) – placed over the array. The frame is centred on its centroid.
align (bool) – turn the region so its long axis lies along x. The rotation is rigid and happens in the equal-area plane, so areas and camera shapes are untouched; only the frame the reader sees turns, which is why the axes stop being azimuth and altitude when this is on.
- Return type:
Projection
- divtel.visualization.region_extent(region, frame=None, align=True)¶
Where a region falls on the page.
- Parameters:
region (divtel.region.SkyRegion)
frame (Projection, optional)
align (bool)
- Returns:
(x_min, x_max, y_min, y_max), in degrees- Return type:
tuple of float
- divtel.visualization.region_span(region, align=True)¶
Height over width of a region on the page, for choosing a figure’s shape.
Panels drawn by multiplicity_over_region are equal-aspect, so a figure not shaped like the region leaves its axes shrunk inside their slots with the colour bars standing beside empty space.
- Parameters:
region (divtel.region.SkyRegion)
align (bool) – must match what the panels are drawn with
- Return type:
float
- divtel.visualization.sky_bands(bands, source=None, centre_ra=None, ax=None, color='#2a78d6', title=None)¶
Credible bands of one localization, all-sky.
Shaded by credible level rather than by raw probability density. Density spans orders of magnitude and plots as a thin bright thread with nothing around it; the bands answer the question actually being asked, which is where the source is likely to be and how confident that is.
An all-sky Mollweide, because a region that runs a hundred degrees does not fit in anything smaller. For the close-up that keeps areas true, see multiplicity_over_region.
- Parameters:
bands (dict of float to divtel.region.SkyRegion) – one region per credible level, in the sky’s own frame – not placed. Drawn outermost first so the darker inner bands land on top.
source (astropy.coordinates.SkyCoord, optional) – a true position, marked if given
centre_ra (astropy.Quantity, optional) – right ascension at the middle of the panel. Pass the same value across panels so several maps can be compared; the innermost band’s mean otherwise.
ax (matplotlib.axes.Axes, optional) – must have a Mollweide projection
color (str) – the hue the bands are shaded in, light to dark
title (str, optional)
- Return type:
matplotlib.axes.Axes
- divtel.visualization.sky_fov(telescope, ax=None)¶
Display the telescope FoV in the sky
- Parameters:
telescope (Telescope)
ax (matplotlib.pyplot.axes)
- Returns:
ax
- Return type:
matplotlib.pyplot.axes
- divtel.visualization.type_colors(array, palette=('#2a78d6', '#eb6834', '#1baf7a', '#eda100'), names=None)¶
A colour per telescope, by the angular radius of its camera.
What every pointing strategy means by a telescope’s type – the same split Array.group_by(“fov_radius”) makes – so a figure coloured this way shows which instrument is being sent where. Free pointing puts the narrow cameras where the map is sharp and the wide ones on the tails, which is a degree of freedom neither divergence nor an even sub-array split can use, and it is invisible without the colours.
- Parameters:
array (divtel.telescope.Array)
palette (sequence of str) – colours to draw from, widest camera first
names (sequence of str, optional) – one name per camera type, widest first, used in the legend instead of the angular radius (e.g.
("SST", "MST")for CTAO-South). Left out, the legend names each group by its field-of-view radius, which is all fov_radius knows – it has no notion of “SST” or “MST”.
- Returns:
colors (list of str) – one per telescope, in
array.telescopesorderlabels (dict) – colour to a legend label for that camera type