Skip to content

Developer's Guide

Jussi Saarivirta edited this page Jul 4, 2023 · 3 revisions

Introduction

Watney is a standalone .NET library that allows you to solve astrophotography images in your .NET programs. It was designed with simplicity of usage in mind, with the ability to also extend and customize it to a degree.

This guide will briefly show you how to adopt it and make it a part of any .NET program.

The libraries

The heart of Watney is in the WatneyAstrometry.Core library, which is available as a Nuget package (https://www.nuget.org/packages/WatneyAstrometry.Core/) as well as here in Github as full source code. It contains the very core of the solver functionality, and is basically the absolute bare bones that you need to get things going. It currently has zero external dependencies, and is built with the .net standard 2.0 profile and is therefore fully portable and also usable by older .NET versions including .NET Framework.

Beside it there's the optional WatneyAstrometry.ImageReaders library (also available as Nuget package) that leverages the SixLabors.ImageSharp library to provide support for the most commonly used image formats: PNG and JPG. It's a plugin by nature, providing a class that implements the IImageReader interface in the core library. In the same way you can easily add support for more image formats, just implement your own custom image reader and you're good to go.

The data

The solver naturally can't function without the star catalog, aka the quad database. That needs to be downloaded and extracted to disk for anything to work. The database is big and is currently hosted in Github, each published database is stored in the Releases section. At the time of writing, the v3 database is documented here and the files are accessible here: https://github.com/Jusas/WatneyAstrometry/releases/tag/watneyqdb3

Direct linking to the files under the release is fine; the Watney Desktop app does exactly this to enable easy downloading of the database files.

Usage

Note: this will be somewhat brief, much of this and more is covered in the API, CLI and Desktop applications' source code and they act as good examples of how to use Watney.

The setup

Add Watney to your project by either adding the WatneyAstrometry.Core Nuget package or by downloading the sources, etc. If you wish to solve PNGs and JPGs then also add WatneyAstrometry.ImageReaders package. The core only supports reading monochrome FITS files, and even that support is quite rudimentary.

Next let's jump into code, we need the quad database to be set up.

using WatneyAstrometry.Core;

...

var quadDatabase = new CompactQuadDatabase();
quadDatabase.UseDataSource("./db");

The above code will create and load the database by loading the indexes into memory (only indexes, not the quad data). Currently loading the entire database content into memory is not supported, the files are streamed from the disk as needed.

The main "entry point" for Watney is the Solver class. There's really not very much more into it.

var solver = new Solver()
  .UseQuadDatabase(quadDatabase)
  .UseImageReader<CommonFormatsImageReader>(() => new CommonFormatsImageReader(), CommonFormatsImageReader.SupportedImageExtensions);

The above code creates a new Solver instance and configures it with the quad database and the PNG/JPG image reader. At this point we have a very basic setup we need for our solver to work.

You can however configure the solver a bit further:

  • A customized star detector can be used. The default one that comes with Watney is pretty simple and if you feel it's lacking, you could always roll out your own. solver.UseStarDetector(IStarDetector) can be used to register a custom star detector. See IStarDetector for the interface.
  • The DefaultStarDetector can be configured in regards to background sensitivity. To tweak it, create a new instance and register that to the solver instead
var d = new DefaultStarDetector(starDetectionBgOffset: 2.0); 
solver.UseStarDetector(d);
  • You can monitor the progress of the solving process by registering OnSolveProgress handler:
solver.OnSolveProgress += ProgressHandler;

private static void ProgressHandler(SolverStep step)
{
    if (step == SolverStep.SolveProcessStarted)
        Console.WriteLine("Solve started...");
    else if (step == SolverStep.SolveProcessFinished)
        Console.WriteLine("Solve finished!");
    else if (step == SolverStep.StarDetectionStarted)
        Console.WriteLine("Detecting stars");
    else if (step == SolverStep.StarDetectionFinished)
        Console.WriteLine("Star detection complete");
    else if (step == SolverStep.ImageReadStarted)
        Console.WriteLine("Reading image data...");
    else if (step == SolverStep.ImageReadFinished)
        Console.WriteLine("Image read");
    else if (step == SolverStep.QuadFormationStarted)
        Console.WriteLine("Forming quads...");
    else if (step == SolverStep.QuadFormationFinished)
        Console.WriteLine("Quads formed");

}
  • You can limit the CPU usage of the solver by limiting how many threads it's allowed to use. This is useful if your program wants to ensure it can use CPU resources while the solver is running. For example:
var globalSolverConfiguration = Solver.SolverGlobalConfiguration;
globalSolverConfiguration.MaxThreads = Environment.ProcessorCount - 1;
Solver.SetGlobalConfiguration(globalSolverConfiguration); // This applies the configuration

Usage

Now that the solver is set up, we need to feed it the image we intend to solve and the parameters for the solve operation.

Solver options

var solverOpts = new SolverOptions();
solverOpts.UseMaxStars = 300; // A good number. If left unset, the solver will dynamically decide itself.
solverOpts.Sampling = 16; // Sampling, see wiki

Max stars can affect the performance quite a bit, the more stars you include, the more calculations are needed - but it also increases the chances of finding a solution. 300 .. 1000 is a good range to go with, usually.

The sampling is explained in the Wiki here - in short, the catalog quads are split into smaller chunks and each chunk gets processed serially, in the hopes of finding a partial solution with fewer computations and then turning it into a full solution.

Search strategy

Search strategy is effectively a description how we are trying to find the solution. There are effectively 3 main search strategies available:

  • PointSearchStrategy - a strategy of trying out a single coordinate with an approximate known radius. Not really a search, but rather "trying out if this information is correct". This exists mainly for debugging purposes, there's not much real value in making this kind of "search".
  • NearbySearchStrategy - a strategy of searching a coordinate and its nearby surroundings for a solution. This is most often used when we have our telescope somewhat aligned, we take an exposure and then see where our telescope is actually pointing at. We know the approximate coordinate, and then look around. See the wiki for a more detailed explanation.
  • BlindSearchStrategy - a strategy of searching when we have no starting information whatsoever of where to look. This is easily the most expensive search strategy. We start methodically scanning the sky, starting from large field radius and going smaller until we either find a result or give up. See the wiki for a more detailed explanation.

Let's assume we have no initial information to go with, so we need to use the Blind strategy:

var strategyOptions = new BlindSearchStrategyOptions()
{
    MaxNegativeDensityOffset = 1,
    MaxPositiveDensityOffset = 1,
    UseParallelism = true, // allow multithreading
    StartRadiusDegrees = 16, // start the search using this field radius
    MinRadiusDegrees = 0.5, // try until we'd go lower than this field radius
    SearchOrderDec = BlindSearchStrategyOptions.DecSearchOrder.NorthFirst // start from the northern sky
};
var strategy = new BlindSearchStrategy(strategyOptions);

Most of that speaks for itself... but what are those two offsets, MaxNegativeDensityOffset/MaxPositiveDensityOffset ?

Controls the inclusion of lower and higher density quad passes. Including more passes in search increases the chance of detection, but also increases solve time. Example: Setting this to 2 will include two lower quad density passes in our search (if available in the quad database).

Generally these are good at 1, but sometimes a solution which cannot be found with values of 1 can be found with a value of 2 - this can happen for example when there are clouds obscuring stars. General guideline: use value of 1, maybe up value to 2 if you're retrying the same solve.

Now let's assume we have initial information of the supposed target coordinate and field radius, so we use the Nearby strategy:

var center = new EquatorialCoords(ra: 10.0, dec: 41.0);
var strategyOptions = new NearbySearchStrategyOptions
{
    MaxNegativeDensityOffset = 1,
    MaxPositiveDensityOffset = 1,
    UseParallelism = true,
    SearchAreaRadiusDegrees = 10 // how far off the center coordinate we will try until we give up,
    MinFieldRadiusDegrees = 0.5,
    MaxFieldRadiusDegrees = 1.0,
    IntermediateFieldRadiusSteps = 2 // how many intermediate field radiuses to try between MinFieldRadiusDegrees .. MaxFieldRadiusDegrees 
};
strategy = new NearbySearchStrategy(center, strategyOptions);

So here we will start from the assumed center point, with the MaxFieldRadiusDegrees as assumed field radius, and start looking. We search until we're off more than 10 degrees off the center, and decrease the field radius until we've found a solution or completed the search with MinFieldRadiusDegrees.

Solve the image

Now that we have the options and the strategy, let's go ahead and solve the image and get the results.

var result = await solver.SolveFieldAsync(imageFilename, strategy, solverOptions,
  CancellationToken.None);

if (result.Success)
{
    outputData.Add("ra", result.Solution.PlateCenter.Ra);
    outputData.Add("dec", result.Solution.PlateCenter.Dec);
    outputData.Add("ra_hms", Conversions.RaDegreesToHhMmSs(result.Solution.PlateCenter.Ra));
    outputData.Add("dec_dms", Conversions.DecDegreesToDdMmSs(result.Solution.PlateCenter.Dec));
    outputData.Add("fieldRadius", result.Solution.Radius);
    outputData.Add("orientation", result.Solution.Orientation);
    outputData.Add("pixScale", result.Solution.PixelScale);
    outputData.Add("parity", result.Solution.Parity.ToString().ToLowerInvariant());
    outputData.Add("starsDetected", result.StarsDetected);
    outputData.Add("starsUsed", result.StarsUsedInSolve);
    outputData.Add("timeSpent", result.TimeSpent.ToString());
    outputData.Add("searchIterations", result.AreasSearched);
    outputData.Add("imageWidth", result.Solution.ImageWidth);
    outputData.Add("imageHeight", result.Solution.ImageHeight);
    outputData.Add("searchRunCenter", result.SearchRun.Center.ToString());
    outputData.Add("searchRunRadius", result.SearchRun.RadiusDegrees);
    outputData.Add("quadMatches", result.MatchedQuads);
    outputData.Add("fieldWidth", result.Solution.FieldWidth);
    outputData.Add("fieldHeight", result.Solution.FieldHeight);
    outputData.Add("fits_cd1_1", result.Solution.FitsHeaders.CD1_1);
    outputData.Add("fits_cd1_2", result.Solution.FitsHeaders.CD1_2);
    outputData.Add("fits_cd2_1", result.Solution.FitsHeaders.CD2_1);
    outputData.Add("fits_cd2_2", result.Solution.FitsHeaders.CD2_2);
    outputData.Add("fits_cdelt1", result.Solution.FitsHeaders.CDELT1);
    outputData.Add("fits_cdelt2", result.Solution.FitsHeaders.CDELT2);
    outputData.Add("fits_crota1", result.Solution.FitsHeaders.CROTA1);
    outputData.Add("fits_crota2", result.Solution.FitsHeaders.CROTA2);
    outputData.Add("fits_crpix1", result.Solution.FitsHeaders.CRPIX1);
    outputData.Add("fits_crpix2", result.Solution.FitsHeaders.CRPIX2);
    outputData.Add("fits_crval1", result.Solution.FitsHeaders.CRVAL1);
    outputData.Add("fits_crval2", result.Solution.FitsHeaders.CRVAL2);
    
}

// We could also write a WCS file with the WcsFitsWriter utility class
using (var wcsStream = new FileStream("myresult.wcs", FileMode.Create))
{
    var wcsWriter = new WcsFitsWriter(wcsStream);
    wcsWriter.WriteWcsFile(result.Solution.FitsHeaders, result.Solution.ImageWidth, result.Solution.ImageHeight);
}

Now that we have the solution, we can use it to see if something is in the image area.

var m31ra = MathUtils.Conversions.RaToDecimal("00 42 44.3");
var m31dec = MathUtils.Conversions.DecToDecimal("41 16 09");
var m31coords = new EquatorialCoords(m31ra, m31dec);

var dsoPosition = solveResult.Solution.EquatorialCoordsToPixel(m31coords);

At this point the solver's job is done. The rest is up to you - the solver itself does not write the results anywhere, and does not contain the functionality to manipulate FITS files. It's up to you what you do with the results.

Another example of executing the Solver can be found here, this is what the Watney Desktop does when the Solve-button is clicked: https://github.com/search?q=repo%3AJusas/WatneyAstrometry%20StartSolve&type=code

Cleaning up

The Solver instance is re-usable, so that if you're planning to use the solver to solve multiple images, you can use the same instance. The same applies to the CompactQuadDatabase. In fact it's wise to not create a new instance for every solve as there's always the initialization overhead when the quad database indexes are read. CompactQuadDatabase does not really use much resources but does use a pool of FileStreams which it disposes when it is disposed. In a normal case, you don't need more than one of each class, but nothing is stopping you from using multiple if you so wish.

References

The best reference is of course existing code, and the Watney repository has 3 slightly different applications you can explore: