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: object

Describe 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: object

Describe 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 id column 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 Arrayload_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: object

A 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=2 is 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_separation and elongation – 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, dec and prob.

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:

  1. see all of the region, not part of it

  2. 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 mismatch in 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:

covered and covered_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; and mismatch.

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_covered beside covered_stereo and neither can flatter the other.

Return type:

dict

Notes

mismatch is 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 beside covered_stereo and 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), sizes and pointings. 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 (or None if the type has fewer than m_cut telescopes); covered and multiplicity, 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) and objective. 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) and cumulative

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 sizes now 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 d from the array’s axis and a distance s along it therefore ends up

arctan(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 asked hyper_fov_area, stereo_area, multiplicity_mean and multiplicity_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, max and rms offset 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:

div and covered at the solution, spread there, and best_div/best_covered – the most the array can cover at all. div is 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 area regardless 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_fov grows 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) and n_fov – the hyper field of view measured in cameras, i.e. hyper_fov_area divided 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: object

A 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: object

A stretch of time when a target is observable.

Variables:
  • end (start,)

  • duration (astropy.Quantity)

  • alt_max (astropy.Quantity) – highest altitude the target reaches inside the window

  • alt_end (alt_start,)

  • time_best (astropy.time.Time) – when it is highest – the moment to plan the pointing for

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.day

  • step (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: object

A 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, in array.telescopes order. "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 them

  • pad (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 rotationdivtel.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.telescopes order

  • labels (dict) – colour to a legend label for that camera type