diff --git a/.editorconfig b/.editorconfig index 00bd20fda..2a6b20ee4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -29,3 +29,6 @@ ij_kotlin_continuation_indent_size = 8 [*.py] ij_python_wrap_long_lines = false ij_python_method_parameters_wrap = off + +[{*.yml, *.yaml}] +ij_yaml_indent_size = 2 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a925fcf27..abfc3e194 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,7 +10,7 @@ updates: directory: "/" schedule: interval: "monthly" - open-pull-requests-limit: 16 + open-pull-requests-limit: 64 target-branch: "dev" commit-message: prefix: "[api]" @@ -21,21 +21,21 @@ updates: netty: patterns: - "io.netty*" - retrofit: - patterns: - - "com.squareup.retrofit2*" - okhttp: + squareup: patterns: - - "com.squareup.okhttp3*" + - "com.squareup*" rx: patterns: - - "io.reactivex.rxjava3*" - jackson: + - "io.reactivex*" + fasterxml: patterns: - - "com.fasterxml.jackson*" + - "com.fasterxml*" kotlin: patterns: - "org.jetbrains.kotlin*" + apache: + patterns: + - "org.apache*" - package-ecosystem: "npm" directory: "/desktop" diff --git a/api/build.gradle.kts b/api/build.gradle.kts index b86cc2805..88cfc8c51 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { implementation(project(":nebulosa-astrometrynet")) implementation(project(":nebulosa-alpaca-indi")) implementation(project(":nebulosa-common")) + implementation(project(":nebulosa-curve-fitting")) implementation(project(":nebulosa-guiding-phd2")) implementation(project(":nebulosa-hips2fits")) implementation(project(":nebulosa-horizons")) @@ -26,7 +27,6 @@ dependencies { implementation(project(":nebulosa-sbd")) implementation(project(":nebulosa-simbad")) implementation(project(":nebulosa-stellarium-protocol")) - implementation(project(":nebulosa-watney")) implementation(project(":nebulosa-wcs")) implementation(project(":nebulosa-xisf")) implementation(libs.rx) diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt index 5889a5907..97d290089 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt @@ -1,13 +1,14 @@ package nebulosa.api.alignment.polar.darv +import nebulosa.api.cameras.CameraEventAware import nebulosa.api.tasks.Job import nebulosa.indi.device.camera.CameraEvent -data class DARVJob(override val task: DARVTask) : Job() { +data class DARVJob(override val task: DARVTask) : Job(), CameraEventAware { override val name = "${task.camera.name} DARV Job" - fun handleCameraEvent(event: CameraEvent) { + override fun handleCameraEvent(event: CameraEvent) { task.handleCameraEvent(event) } } diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVTask.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVTask.kt index 1a6041ab4..65e4ea05c 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVTask.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVTask.kt @@ -1,10 +1,7 @@ package nebulosa.api.alignment.polar.darv import io.reactivex.rxjava3.functions.Consumer -import nebulosa.api.cameras.AutoSubFolderMode -import nebulosa.api.cameras.CameraCaptureEvent -import nebulosa.api.cameras.CameraCaptureState -import nebulosa.api.cameras.CameraCaptureTask +import nebulosa.api.cameras.* import nebulosa.api.guiding.GuidePulseEvent import nebulosa.api.guiding.GuidePulseRequest import nebulosa.api.guiding.GuidePulseTask @@ -30,11 +27,11 @@ data class DARVTask( @JvmField val guideOutput: GuideOutput, @JvmField val request: DARVStartRequest, private val executor: Executor, -) : AbstractTask(), Consumer { +) : AbstractTask(), Consumer, CameraEventAware { @JvmField val cameraRequest = request.capture.copy( exposureTime = request.capture.exposureTime + request.capture.exposureDelay, - savePath = Files.createTempDirectory("darv"), + savePath = CAPTURE_SAVE_PATH, exposureAmount = 1, exposureDelay = Duration.ZERO, frameType = FrameType.LIGHT, autoSave = false, autoSubFolderMode = AutoSubFolderMode.OFF ) @@ -60,7 +57,7 @@ data class DARVTask( backwardGuidePulseTask.subscribe(this) } - fun handleCameraEvent(event: CameraEvent) { + override fun handleCameraEvent(event: CameraEvent) { cameraCaptureTask.handleCameraEvent(event) } @@ -126,5 +123,6 @@ data class DARVTask( companion object { @JvmStatic private val LOG = loggerFor() + @JvmStatic private val CAPTURE_SAVE_PATH = Files.createTempDirectory("darv-") } } diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt index d4b900519..c7808faaa 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt @@ -4,10 +4,10 @@ import io.reactivex.rxjava3.functions.Consumer import nebulosa.api.beans.annotations.Subscriber import nebulosa.api.messages.MessageEvent import nebulosa.api.messages.MessageService -import nebulosa.api.solver.PlateSolverService import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.camera.CameraEvent import nebulosa.indi.device.mount.Mount +import okhttp3.OkHttpClient import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.springframework.stereotype.Component @@ -17,7 +17,7 @@ import java.util.concurrent.ConcurrentHashMap @Subscriber class TPPAExecutor( private val messageService: MessageService, - private val plateSolverService: PlateSolverService, + private val httpClient: OkHttpClient, ) : Consumer { private val jobs = ConcurrentHashMap.newKeySet(1) @@ -38,7 +38,7 @@ class TPPAExecutor( check(jobs.none { it.task.camera === camera }) { "${camera.name} TPPA Job is already in progress" } check(jobs.none { it.task.mount === mount }) { "${camera.name} TPPA Job is already in progress" } - val solver = plateSolverService.solverFor(request.plateSolver) + val solver = request.plateSolver.get(httpClient) val task = TPPATask(camera, solver, request, mount) task.subscribe(this) diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt index a11a45abe..e71b4e21b 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt @@ -1,13 +1,14 @@ package nebulosa.api.alignment.polar.tppa +import nebulosa.api.cameras.CameraEventAware import nebulosa.api.tasks.Job import nebulosa.indi.device.camera.CameraEvent -data class TPPAJob(override val task: TPPATask) : Job() { +data class TPPAJob(override val task: TPPATask) : Job(), CameraEventAware { override val name = "${task.camera.name} TPPA Job" - fun handleCameraEvent(event: CameraEvent) { + override fun handleCameraEvent(event: CameraEvent) { task.handleCameraEvent(event) } } diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPATask.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPATask.kt index 3664401cc..e3ae706ff 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPATask.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPATask.kt @@ -3,10 +3,7 @@ package nebulosa.api.alignment.polar.tppa import io.reactivex.rxjava3.functions.Consumer import nebulosa.alignment.polar.point.three.ThreePointPolarAlignment import nebulosa.alignment.polar.point.three.ThreePointPolarAlignmentResult -import nebulosa.api.cameras.AutoSubFolderMode -import nebulosa.api.cameras.CameraCaptureEvent -import nebulosa.api.cameras.CameraCaptureState -import nebulosa.api.cameras.CameraCaptureTask +import nebulosa.api.cameras.* import nebulosa.api.messages.MessageEvent import nebulosa.api.mounts.MountMoveRequest import nebulosa.api.mounts.MountMoveTask @@ -38,12 +35,12 @@ data class TPPATask( @JvmField val mount: Mount? = null, @JvmField val longitude: Angle = mount!!.longitude, @JvmField val latitude: Angle = mount!!.latitude, -) : AbstractTask(), Consumer, PauseListener { +) : AbstractTask(), Consumer, PauseListener, CameraEventAware { @JvmField val mountMoveRequest = MountMoveRequest(request.stepDirection, request.stepDuration, request.stepSpeed) @JvmField val cameraRequest = request.capture.copy( - savePath = Files.createTempDirectory("tppa"), + savePath = CAPTURE_SAVE_PATH, exposureAmount = 0, exposureDelay = Duration.ZERO, exposureTime = maxOf(request.capture.exposureTime, MIN_EXPOSURE_TIME), frameType = FrameType.LIGHT, autoSave = false, autoSubFolderMode = AutoSubFolderMode.OFF @@ -73,7 +70,7 @@ data class TPPATask( settleDelayTask.subscribe(this) } - fun handleCameraEvent(event: CameraEvent) { + override fun handleCameraEvent(event: CameraEvent) { if (camera === event.device) { cameraCaptureTask.handleCameraEvent(event) } @@ -117,14 +114,14 @@ data class TPPATask( cancellationToken.listenToPause(this) - while (!cancellationToken.isDone) { + while (!cancellationToken.isCancelled) { if (cancellationToken.isPaused) { pausing.set(false) sendEvent(TPPAState.PAUSED) cancellationToken.waitForPause() } - if (cancellationToken.isDone) break + if (cancellationToken.isCancelled) break mount?.tracking(true) @@ -137,7 +134,7 @@ data class TPPATask( mountMoveState[alignment.state.ordinal] = true } - if (cancellationToken.isDone) break + if (cancellationToken.isCancelled) break rightAscension = mount.rightAscension declination = mount.declination @@ -149,14 +146,14 @@ data class TPPATask( } } - if (cancellationToken.isDone) break + if (cancellationToken.isCancelled) break sendEvent(TPPAState.EXPOSURING) // CAPTURE. cameraCaptureTask.execute(cancellationToken) - if (cancellationToken.isDone || savedImage == null) { + if (cancellationToken.isCancelled || savedImage == null) { break } @@ -180,7 +177,7 @@ data class TPPATask( LOG.info("TPPA alignment completed. result=$result") - if (cancellationToken.isDone) break + if (cancellationToken.isCancelled) break when (result) { is ThreePointPolarAlignmentResult.NeedMoreMeasurement -> { @@ -313,6 +310,7 @@ data class TPPATask( @JvmStatic private val MIN_EXPOSURE_TIME = Duration.ofSeconds(1L) @JvmStatic private val SETTLE_TIME = Duration.ofSeconds(5) + @JvmStatic private val CAPTURE_SAVE_PATH = Files.createTempDirectory("tppa-") @JvmStatic private val LOG = loggerFor() const val MAX_ATTEMPTS = 30 diff --git a/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusController.kt b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusController.kt new file mode 100644 index 000000000..1b2534adf --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusController.kt @@ -0,0 +1,22 @@ +package nebulosa.api.autofocus + +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.focuser.Focuser +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("auto-focus") +class AutoFocusController(private val autoFocusService: AutoFocusService) { + + @PutMapping("{camera}/{focuser}/start") + fun start( + camera: Camera, focuser: Focuser, + @RequestBody body: AutoFocusRequest, + ) = autoFocusService.start(camera, focuser, body) + + @PutMapping("{camera}/stop") + fun stop(camera: Camera) = autoFocusService.stop(camera) + + @GetMapping("{camera}/status") + fun status(camera: Camera) = autoFocusService.status(camera) +} diff --git a/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusEvent.kt b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusEvent.kt new file mode 100644 index 000000000..334f44d59 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusEvent.kt @@ -0,0 +1,32 @@ +package nebulosa.api.autofocus + +import nebulosa.api.cameras.CameraCaptureEvent +import nebulosa.api.messages.MessageEvent +import nebulosa.curve.fitting.CurvePoint +import nebulosa.curve.fitting.HyperbolicFitting +import nebulosa.curve.fitting.QuadraticFitting +import nebulosa.curve.fitting.TrendLineFitting + +data class AutoFocusEvent( + @JvmField val state: AutoFocusState = AutoFocusState.IDLE, + @JvmField val focusPoint: CurvePoint? = null, + @JvmField val determinedFocusPoint: CurvePoint? = null, + @JvmField val starCount: Int = 0, + @JvmField val starHFD: Double = 0.0, + @JvmField val chart: Chart? = null, + @JvmField val capture: CameraCaptureEvent? = null, +) : MessageEvent { + + data class Chart( + @JvmField val predictedFocusPoint: CurvePoint? = null, + @JvmField val minX: Double = 0.0, + @JvmField val minY: Double = 0.0, + @JvmField val maxX: Double = 0.0, + @JvmField val maxY: Double = 0.0, + @JvmField val trendLine: TrendLineFitting.Curve? = null, + @JvmField val parabolic: QuadraticFitting.Curve? = null, + @JvmField val hyperbolic: HyperbolicFitting.Curve? = null, + ) + + override val eventName = "AUTO_FOCUS.ELAPSED" +} diff --git a/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusEventChartSerializer.kt b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusEventChartSerializer.kt new file mode 100644 index 000000000..ff36ce31c --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusEventChartSerializer.kt @@ -0,0 +1,115 @@ +package nebulosa.api.autofocus + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import nebulosa.curve.fitting.* +import nebulosa.nova.almanac.evenlySpacedNumbers +import org.springframework.stereotype.Component +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +@Component +class AutoFocusEventChartSerializer : StdSerializer(AutoFocusEvent.Chart::class.java) { + + override fun serialize(chart: AutoFocusEvent.Chart?, gen: JsonGenerator, provider: SerializerProvider) { + if (chart == null) { + gen.writeNull() + } else { + gen.writeStartObject() + + gen.writePOJOField("predictedFocusPoint", chart.predictedFocusPoint) + gen.writeNumberField("minX", chart.minX) + gen.writeNumberField("minY", chart.minY) + gen.writeNumberField("maxX", chart.maxX) + gen.writeNumberField("maxY", chart.maxY) + + if (chart.trendLine != null || chart.parabolic != null || chart.hyperbolic != null) { + val delta = chart.maxX - chart.minX + val stepSize = max(3, min((delta / 10.0).roundToInt().let { if (it % 2 == 0) it + 1 else it }, 101)) + val points = if (delta <= 0.0) doubleArrayOf(chart.minX) else evenlySpacedNumbers(chart.minX, chart.maxX, stepSize) + chart.trendLine?.serialize(gen, points) + chart.parabolic?.serialize(gen, points) + chart.hyperbolic?.serialize(gen, points) + } + + gen.writeEndObject() + } + } + + companion object { + + @Suppress("NOTHING_TO_INLINE") + private inline fun Double.isRSquaredValid() = isFinite() && this > 0.0 + + private inline fun T?.serializeAsFittedCurve(gen: JsonGenerator, fieldName: String, block: (T) -> Unit = {}) { + if (this != null && rSquared.isRSquaredValid()) { + gen.writeObjectFieldStart(fieldName) + gen.writeNumberField("rSquared", rSquared) + gen.writePOJOField("minimum", minimum) + block(this) + gen.writeEndObject() + } + } + + @JvmStatic + private fun TrendLineFitting.Curve?.serialize(gen: JsonGenerator, points: DoubleArray) { + serializeAsFittedCurve(gen, "trendLine") { + it.left.serialize(gen, "left", points) + it.right.serialize(gen, "right", points) + gen.writePOJOField("intersection", it.intersection) + } + } + + @JvmStatic + private fun TrendLine.serialize(gen: JsonGenerator, fieldName: String, points: DoubleArray) { + gen.writeObjectFieldStart(fieldName) + gen.writeNumberField("slope", slope) + gen.writeNumberField("intercept", intercept) + gen.writeNumberField("rSquared", rSquared) + + if (rSquared.isRSquaredValid()) { + makePoints(gen, points) + } + + gen.writeEndObject() + } + + @JvmStatic + private fun QuadraticFitting.Curve?.serialize(gen: JsonGenerator, points: DoubleArray) { + serializeAsFittedCurve(gen, "parabolic") { + if (it.rSquared.isRSquaredValid()) { + it.makePoints(gen, points) + } + } + } + + @JvmStatic + private fun HyperbolicFitting.Curve?.serialize(gen: JsonGenerator, points: DoubleArray) { + serializeAsFittedCurve(gen, "hyperbolic") { + gen.writeNumberField("a", it.a) + gen.writeNumberField("b", it.b) + gen.writeNumberField("p", it.p) + + if (it.rSquared.isRSquaredValid()) { + it.makePoints(gen, points) + } + } + } + + @JvmStatic + private fun Curve.makePoints(gen: JsonGenerator, points: DoubleArray) { + gen.writeArrayFieldStart("points") + + for (x in points) { + gen.writeStartObject() + gen.writeNumberField("x", x) + gen.writeNumberField("y", this(x)) + gen.writeEndObject() + } + + gen.writeEndArray() + } + } +} diff --git a/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusExecutor.kt b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusExecutor.kt new file mode 100644 index 000000000..bfdd6698d --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusExecutor.kt @@ -0,0 +1,63 @@ +package nebulosa.api.autofocus + +import io.reactivex.rxjava3.functions.Consumer +import nebulosa.api.beans.annotations.Subscriber +import nebulosa.api.messages.MessageEvent +import nebulosa.api.messages.MessageService +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.camera.CameraEvent +import nebulosa.indi.device.focuser.Focuser +import nebulosa.indi.device.focuser.FocuserEvent +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.springframework.stereotype.Component +import java.util.concurrent.ConcurrentHashMap + +@Component +@Subscriber +class AutoFocusExecutor( + private val messageService: MessageService, +) : Consumer { + + private val jobs = ConcurrentHashMap.newKeySet(2) + + @Subscribe(threadMode = ThreadMode.ASYNC) + fun onCameraEvent(event: CameraEvent) { + jobs.find { it.task.camera === event.device }?.handleCameraEvent(event) + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + fun onFocuserEvent(event: FocuserEvent) { + jobs.find { it.task.focuser === event.device }?.handleFocuserEvent(event) + } + + override fun accept(event: MessageEvent) { + messageService.sendMessage(event) + } + + @Synchronized + fun execute(camera: Camera, focuser: Focuser, request: AutoFocusRequest) { + check(camera.connected) { "${camera.name} Camera is not connected" } + check(focuser.connected) { "${focuser.name} Camera is not connected" } + check(jobs.none { it.task.camera === camera }) { "${camera.name} Auto Focus is already in progress" } + check(jobs.none { it.task.focuser === focuser }) { "${camera.name} Auto Focus is already in progress" } + + val starDetector = request.starDetector.get() + val task = AutoFocusTask(camera, focuser, request, starDetector) + task.subscribe(this) + + with(AutoFocusJob(task)) { + jobs.add(this) + whenComplete { _, _ -> jobs.remove(this) } + start() + } + } + + fun stop(camera: Camera) { + jobs.find { it.task.camera === camera }?.stop() + } + + fun status(camera: Camera): AutoFocusEvent? { + return jobs.find { it.task.camera === camera }?.task?.get() as? AutoFocusEvent + } +} diff --git a/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusFittingMode.kt b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusFittingMode.kt new file mode 100644 index 000000000..ea43d243a --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusFittingMode.kt @@ -0,0 +1,9 @@ +package nebulosa.api.autofocus + +enum class AutoFocusFittingMode { + TRENDLINES, + PARABOLIC, + TREND_PARABOLIC, + HYPERBOLIC, + TREND_HYPERBOLIC +} diff --git a/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusJob.kt b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusJob.kt new file mode 100644 index 000000000..f1e807513 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusJob.kt @@ -0,0 +1,20 @@ +package nebulosa.api.autofocus + +import nebulosa.api.cameras.CameraEventAware +import nebulosa.api.focusers.FocuserEventAware +import nebulosa.api.tasks.Job +import nebulosa.indi.device.camera.CameraEvent +import nebulosa.indi.device.focuser.FocuserEvent + +data class AutoFocusJob(override val task: AutoFocusTask) : Job(), CameraEventAware, FocuserEventAware { + + override val name = "${task.camera.name} Auto Focus Job" + + override fun handleCameraEvent(event: CameraEvent) { + task.handleCameraEvent(event) + } + + override fun handleFocuserEvent(event: FocuserEvent) { + task.handleFocuserEvent(event) + } +} diff --git a/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusRequest.kt b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusRequest.kt new file mode 100644 index 000000000..5f1c5edd1 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusRequest.kt @@ -0,0 +1,16 @@ +package nebulosa.api.autofocus + +import nebulosa.api.cameras.CameraStartCaptureRequest +import nebulosa.api.focusers.BacklashCompensation +import nebulosa.api.stardetection.StarDetectionOptions + +data class AutoFocusRequest( + @JvmField val fittingMode: AutoFocusFittingMode = AutoFocusFittingMode.HYPERBOLIC, + @JvmField val capture: CameraStartCaptureRequest = CameraStartCaptureRequest.EMPTY, + @JvmField val rSquaredThreshold: Double = 0.5, + @JvmField val backlashCompensation: BacklashCompensation = BacklashCompensation.EMPTY, + @JvmField val initialOffsetSteps: Int = 4, + @JvmField val stepSize: Int = 50, + @JvmField val totalNumberOfAttempts: Int = 1, + @JvmField val starDetector: StarDetectionOptions = StarDetectionOptions.EMPTY, +) diff --git a/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusService.kt b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusService.kt new file mode 100644 index 000000000..fc70aaef5 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusService.kt @@ -0,0 +1,23 @@ +package nebulosa.api.autofocus + +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.focuser.Focuser +import org.springframework.stereotype.Service + +@Service +class AutoFocusService( + private val autoFocusExecutor: AutoFocusExecutor, +) { + + fun start(camera: Camera, focuser: Focuser, body: AutoFocusRequest) { + autoFocusExecutor.execute(camera, focuser, body) + } + + fun stop(camera: Camera) { + autoFocusExecutor.stop(camera) + } + + fun status(camera: Camera): AutoFocusEvent? { + return autoFocusExecutor.status(camera) + } +} diff --git a/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusState.kt b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusState.kt new file mode 100644 index 000000000..e9cc57521 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusState.kt @@ -0,0 +1,13 @@ +package nebulosa.api.autofocus + +enum class AutoFocusState { + IDLE, + MOVING, + EXPOSURING, + EXPOSURED, + ANALYSING, + ANALYSED, + CURVE_FITTED, + FAILED, + FINISHED, +} diff --git a/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusTask.kt b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusTask.kt new file mode 100644 index 000000000..249e5c51d --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusTask.kt @@ -0,0 +1,411 @@ +package nebulosa.api.autofocus + +import io.reactivex.rxjava3.functions.Consumer +import nebulosa.api.cameras.* +import nebulosa.api.focusers.BacklashCompensationFocuserMoveTask +import nebulosa.api.focusers.BacklashCompensationMode +import nebulosa.api.focusers.FocuserEventAware +import nebulosa.api.messages.MessageEvent +import nebulosa.api.tasks.AbstractTask +import nebulosa.common.concurrency.cancel.CancellationToken +import nebulosa.curve.fitting.CurvePoint +import nebulosa.curve.fitting.CurvePoint.Companion.midPoint +import nebulosa.curve.fitting.HyperbolicFitting +import nebulosa.curve.fitting.QuadraticFitting +import nebulosa.curve.fitting.TrendLineFitting +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.camera.CameraEvent +import nebulosa.indi.device.camera.FrameType +import nebulosa.indi.device.focuser.Focuser +import nebulosa.indi.device.focuser.FocuserEvent +import nebulosa.log.loggerFor +import nebulosa.star.detection.ImageStar +import nebulosa.star.detection.StarDetector +import java.nio.file.Files +import java.nio.file.Path +import java.time.Duration +import kotlin.math.max +import kotlin.math.roundToInt + +data class AutoFocusTask( + @JvmField val camera: Camera, + @JvmField val focuser: Focuser, + @JvmField val request: AutoFocusRequest, + @JvmField val starDetection: StarDetector, +) : AbstractTask(), Consumer, CameraEventAware, FocuserEventAware { + + @JvmField val cameraRequest = request.capture.copy( + exposureAmount = 0, exposureDelay = Duration.ZERO, + savePath = CAPTURE_SAVE_PATH, + exposureTime = maxOf(request.capture.exposureTime, MIN_EXPOSURE_TIME), + frameType = FrameType.LIGHT, autoSave = false, autoSubFolderMode = AutoSubFolderMode.OFF + ) + + private val focusPoints = ArrayList() + private val measurements = DoubleArray(request.capture.exposureAmount) + private val cameraCaptureTask = CameraCaptureTask(camera, cameraRequest, exposureMaxRepeat = max(1, request.capture.exposureAmount)) + private val focuserMoveTask = BacklashCompensationFocuserMoveTask(focuser, 0, request.backlashCompensation) + + @Volatile private var trendLineCurve: TrendLineFitting.Curve? = null + @Volatile private var parabolicCurve: QuadraticFitting.Curve? = null + @Volatile private var hyperbolicCurve: HyperbolicFitting.Curve? = null + + @Volatile private var measurementPos = 0 + @Volatile private var focusPoint: CurvePoint? = null + @Volatile private var starCount = 0 + @Volatile private var starHFD = 0.0 + @Volatile private var determinedFocusPoint: CurvePoint? = null + + init { + cameraCaptureTask.subscribe(this) + } + + override fun handleCameraEvent(event: CameraEvent) { + cameraCaptureTask.handleCameraEvent(event) + } + + override fun handleFocuserEvent(event: FocuserEvent) { + focuserMoveTask.handleFocuserEvent(event) + } + + override fun canUseAsLastEvent(event: MessageEvent) = event is AutoFocusEvent + + override fun execute(cancellationToken: CancellationToken) { + reset() + + val initialFocusPosition = focuser.position + + // Get initial position information, as average of multiple exposures, if configured this way. + val initialHFD = if (request.rSquaredThreshold <= 0.0) takeExposure(cancellationToken) else 0.0 + val reverse = request.backlashCompensation.mode == BacklashCompensationMode.OVERSHOOT && request.backlashCompensation.backlashIn > 0 + + LOG.info("Auto Focus started. initialHFD={}, reverse={}, request={}, camera={}, focuser={}", initialHFD, reverse, request, camera, focuser) + + var exited = false + var numberOfAttempts = 0 + val maximumFocusPoints = request.capture.exposureAmount * request.initialOffsetSteps * 10 + + // camera.snoop(listOf(focuser)) + + while (!exited && !cancellationToken.isCancelled) { + numberOfAttempts++ + + val offsetSteps = request.initialOffsetSteps + val numberOfSteps = offsetSteps + 1 + + LOG.info("attempt #{}. offsetSteps={}, numberOfSteps={}", numberOfAttempts, offsetSteps, numberOfSteps) + + obtainFocusPoints(numberOfSteps, offsetSteps, reverse, cancellationToken) + + if (cancellationToken.isCancelled) break + + var leftCount = trendLineCurve?.left?.points?.size ?: 0 + var rightCount = trendLineCurve?.right?.points?.size ?: 0 + + LOG.info("trend line computed. left=$leftCount, right=$rightCount") + + // When data points are not sufficient analyze and take more. + do { + if (leftCount == 0 && rightCount == 0) { + LOG.warn("Not enought spreaded points") + exited = true + break + } + + LOG.info("data points are not sufficient. attempt={}, numberOfSteps={}", numberOfAttempts, numberOfSteps) + + // Let's keep moving in, one step at a time, until we have enough left trend points. + // Then we can think about moving out to fill in the right trend points. + if (trendLineCurve!!.left.points.size < offsetSteps + && focusPoints.count { it.x < trendLineCurve!!.minimum.x && it.y == 0.0 } < offsetSteps + ) { + LOG.info("more data points needed to the left of the minimum") + + // Move to the leftmost point - this should never be necessary since we're already there, but just in case + if (focuser.position != focusPoints.first().x.roundToInt()) { + moveFocuser(focusPoints.first().x.roundToInt(), cancellationToken, false) + } + + // More points needed to the left. + obtainFocusPoints(1, -1, false, cancellationToken) + } else if (trendLineCurve!!.right.points.size < offsetSteps + && focusPoints.count { it.x > trendLineCurve!!.minimum.x && it.y == 0.0 } < offsetSteps + ) { + // Now we can go to the right, if necessary. + LOG.info("more data points needed to the right of the minimum") + + // More points needed to the right. Let's get to the rightmost point, and keep going right one point at a time. + if (focuser.position != focusPoints.last().x.roundToInt()) { + moveFocuser(focusPoints.last().x.roundToInt(), cancellationToken, false) + } + + // More points needed to the right. + obtainFocusPoints(1, 1, false, cancellationToken) + } + + if (cancellationToken.isCancelled) break + + leftCount = trendLineCurve!!.left.points.size + rightCount = trendLineCurve!!.right.points.size + + LOG.info("trend line computed. left=$leftCount, right=$rightCount") + + if (maximumFocusPoints < focusPoints.size) { + // Break out when the maximum limit of focus points is reached + LOG.error("failed to complete. Maximum number of focus points exceeded ($maximumFocusPoints).") + break + } + + if (focuser.position <= 0 || focuser.position >= focuser.maxPosition) { + // Break out when the focuser hits the min/max position. It can't continue from there. + LOG.error("failed to complete. position reached ${focuser.position}") + break + } + } while (!cancellationToken.isCancelled && (rightCount + focusPoints.count { it.x > trendLineCurve!!.minimum.x && it.y == 0.0 } < offsetSteps || leftCount + focusPoints.count { it.x < trendLineCurve!!.minimum.x && it.y == 0.0 } < offsetSteps)) + + if (exited || cancellationToken.isCancelled) break + + val finalFocusPoint = determineFinalFocusPoint() + + if (finalFocusPoint == null || !validateCalculatedFocusPosition(finalFocusPoint, initialHFD, cancellationToken)) { + if (cancellationToken.isCancelled) { + break + } else if (numberOfAttempts < request.totalNumberOfAttempts) { + moveFocuser(initialFocusPosition, cancellationToken, false) + LOG.warn("potentially bad auto-focus. Reattempting") + reset() + continue + } else { + LOG.warn("potentially bad auto-focus. Restoring original focus position") + exited = true + } + } else { + determinedFocusPoint = finalFocusPoint + LOG.info("Auto Focus completed. x={}, y={}", finalFocusPoint.x, finalFocusPoint.y) + break + } + } + + if (exited || cancellationToken.isCancelled) { + LOG.warn("Auto Focus did not complete successfully, so restoring the focuser position to $initialFocusPosition") + sendEvent(if (exited) AutoFocusState.FAILED else AutoFocusState.FINISHED) + + if (exited) { + moveFocuser(initialFocusPosition, CancellationToken.NONE, false) + } + } else { + sendEvent(AutoFocusState.FINISHED) + } + + reset() + + LOG.info("Auto Focus finished. camera={}, focuser={}", camera, focuser) + } + + private fun determineFinalFocusPoint(): CurvePoint? { + return when (request.fittingMode) { + AutoFocusFittingMode.TRENDLINES -> trendLineCurve!!.intersection + AutoFocusFittingMode.PARABOLIC -> parabolicCurve?.minimum + AutoFocusFittingMode.TREND_PARABOLIC -> parabolicCurve?.minimum?.midPoint(trendLineCurve!!.intersection) + AutoFocusFittingMode.HYPERBOLIC -> hyperbolicCurve?.minimum + AutoFocusFittingMode.TREND_HYPERBOLIC -> hyperbolicCurve?.minimum?.midPoint(trendLineCurve!!.intersection) + } + } + + private fun evaluateAllMeasurements(): Double { + return if (measurements.isEmpty()) 0.0 else measurements.average() + } + + override fun accept(event: CameraCaptureEvent) { + if (event.state == CameraCaptureState.EXPOSURE_FINISHED) { + sendEvent(AutoFocusState.EXPOSURED, event) + sendEvent(AutoFocusState.ANALYSING) + val detectedStars = starDetection.detect(event.savePath!!) + starCount = detectedStars.size + LOG.info("detected $starCount stars") + starHFD = detectedStars.measureDetectedStars() + LOG.info("HFD measurement. mean={}", starHFD) + measurements[measurementPos++] = starHFD + sendEvent(AutoFocusState.ANALYSED) + onNext(event) + } else { + sendEvent(AutoFocusState.EXPOSURING, event) + } + } + + private fun takeExposure(cancellationToken: CancellationToken): Double { + return if (!cancellationToken.isCancelled) { + measurementPos = 0 + sendEvent(AutoFocusState.EXPOSURING) + cameraCaptureTask.execute(cancellationToken) + evaluateAllMeasurements() + } else { + 0.0 + } + } + + private fun obtainFocusPoints(numberOfSteps: Int, offset: Int, reverse: Boolean, cancellationToken: CancellationToken) { + val stepSize = request.stepSize + val direction = if (reverse) -1 else 1 + + LOG.info("retrieving focus points. numberOfSteps={}, offset={}, reverse={}", numberOfSteps, offset, reverse) + + var focusPosition = 0 + + if (offset != 0) { + focusPosition = moveFocuser(direction * offset * stepSize, cancellationToken, true) + } + + var remainingSteps = numberOfSteps + + while (!cancellationToken.isCancelled && remainingSteps > 0) { + val currentFocusPosition = focusPosition + + val measurement = takeExposure(cancellationToken) + + if (cancellationToken.isCancelled) break + + LOG.info("HFD measured after exposures. mean={}", measurement) + + if (remainingSteps-- > 1) { + focusPosition = moveFocuser(direction * -stepSize, cancellationToken, true) + } + + if (cancellationToken.isCancelled) break + + // If star measurement is 0, we didn't detect any stars or shapes, + // and want this point to be ignored by the fitting as much as possible. + if (measurement == 0.0) { + LOG.warn("No stars detected in step") + } else { + focusPoint = CurvePoint(currentFocusPosition.toDouble(), measurement) + focusPoints.add(focusPoint!!) + focusPoints.sortBy { it.x } + + LOG.info("focus point added. remainingSteps={}, point={}", remainingSteps, focusPoint) + + computeCurveFittings() + } + } + } + + private fun computeCurveFittings() { + with(focusPoints) { + trendLineCurve = TrendLineFitting.calculate(this) + + if (size >= 3) { + if (request.fittingMode == AutoFocusFittingMode.PARABOLIC || request.fittingMode == AutoFocusFittingMode.TREND_PARABOLIC) { + parabolicCurve = QuadraticFitting.calculate(this) + } else if (request.fittingMode == AutoFocusFittingMode.HYPERBOLIC || request.fittingMode == AutoFocusFittingMode.TREND_HYPERBOLIC) { + hyperbolicCurve = HyperbolicFitting.calculate(this) + } + } + + sendEvent(AutoFocusState.CURVE_FITTED) + } + } + + private fun validateCalculatedFocusPosition(focusPoint: CurvePoint, initialHFD: Double, cancellationToken: CancellationToken): Boolean { + val threshold = request.rSquaredThreshold + + LOG.info("validating calculated focus position. threshold={}", threshold) + + if (threshold > 0.0) { + fun isTrendLineBad() = trendLineCurve?.let { it.left.rSquared < threshold || it.right.rSquared < threshold } ?: true + fun isParabolicBad() = parabolicCurve?.let { it.rSquared < threshold } ?: true + fun isHyperbolicBad() = hyperbolicCurve?.let { it.rSquared < threshold } ?: true + + val isBad = when (request.fittingMode) { + AutoFocusFittingMode.TRENDLINES -> isTrendLineBad() + AutoFocusFittingMode.PARABOLIC -> isParabolicBad() + AutoFocusFittingMode.TREND_PARABOLIC -> isParabolicBad() || isTrendLineBad() + AutoFocusFittingMode.HYPERBOLIC -> isHyperbolicBad() + AutoFocusFittingMode.TREND_HYPERBOLIC -> isHyperbolicBad() || isTrendLineBad() + } + + if (isBad) { + LOG.error("coefficient of determination is below threshold") + return false + } + } + + val min = focusPoints.first().x + val max = focusPoints.last().x + + if (focusPoint.x < min || focusPoint.x > max) { + LOG.error("determined focus point position is outside of the overall measurement points of the curve") + return false + } + + if (cancellationToken.isCancelled) return false + + moveFocuser(focusPoint.x.roundToInt(), cancellationToken, false) + val hfd = takeExposure(cancellationToken) + + if (threshold <= 0) { + if (initialHFD != 0.0 && hfd > initialHFD * 1.15) { + LOG.warn("New focus point HFR $hfd is significantly worse than original HFR $initialHFD") + return false + } + } + + return true + } + + private fun moveFocuser(position: Int, cancellationToken: CancellationToken, relative: Boolean): Int { + sendEvent(AutoFocusState.MOVING) + focuserMoveTask.position = if (relative) focuser.position + position else position + focuserMoveTask.execute(cancellationToken) + return focuser.position + } + + private fun sendEvent(state: AutoFocusState, capture: CameraCaptureEvent? = null) { + val chart = when (state) { + AutoFocusState.FINISHED, + AutoFocusState.CURVE_FITTED -> { + val predictedFocusPoint = determinedFocusPoint ?: determineFinalFocusPoint() + val (minX, minY) = if (focusPoints.isEmpty()) CurvePoint.ZERO else focusPoints[0] + val (maxX, maxY) = if (focusPoints.isEmpty()) CurvePoint.ZERO else focusPoints[focusPoints.lastIndex] + AutoFocusEvent.Chart(predictedFocusPoint, minX, minY, maxX, maxY, trendLineCurve, parabolicCurve, hyperbolicCurve) + } + else -> null + } + + onNext(AutoFocusEvent(state, focusPoint, determinedFocusPoint, starCount, starHFD, chart, capture)) + } + + override fun reset() { + cameraCaptureTask.reset() + focusPoints.clear() + + trendLineCurve = null + parabolicCurve = null + hyperbolicCurve = null + } + + override fun close() { + super.close() + cameraCaptureTask.close() + } + + companion object { + + @JvmStatic private val MIN_EXPOSURE_TIME = Duration.ofSeconds(1L) + @JvmStatic private val CAPTURE_SAVE_PATH = Files.createTempDirectory("af-") + @JvmStatic private val LOG = loggerFor() + + @JvmStatic + private fun DoubleArray.median(): Double { + return if (size % 2 == 0) (this[size / 2] + this[size / 2 - 1]) / 2.0 + else this[size / 2] + } + + @JvmStatic + private fun List.measureDetectedStars(): Double { + return if (isEmpty()) 0.0 + else if (size == 1) this[0].hfd + else if (size == 2) (this[0].hfd + this[1].hfd) / 2.0 + else DoubleArray(size) { this[it].hfd }.also { it.sort() }.median() + } + } +} diff --git a/api/src/main/kotlin/nebulosa/api/autofocus/CurvePointSerializer.kt b/api/src/main/kotlin/nebulosa/api/autofocus/CurvePointSerializer.kt new file mode 100644 index 000000000..3d5db1549 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/autofocus/CurvePointSerializer.kt @@ -0,0 +1,21 @@ +package nebulosa.api.autofocus + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import nebulosa.curve.fitting.CurvePoint +import org.springframework.stereotype.Component + +@Component +class CurvePointSerializer : StdSerializer(CurvePoint::class.java) { + + override fun serialize(point: CurvePoint?, gen: JsonGenerator, provider: SerializerProvider) { + if (point == null) gen.writeNull() + else { + gen.writeStartObject() + gen.writeNumberField("x", point.x) + gen.writeNumberField("y", point.y) + gen.writeEndObject() + } + } +} diff --git a/api/src/main/kotlin/nebulosa/api/beans/configurations/BeanConfiguration.kt b/api/src/main/kotlin/nebulosa/api/beans/configurations/BeanConfiguration.kt index fe3a7d905..b03a02883 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/configurations/BeanConfiguration.kt +++ b/api/src/main/kotlin/nebulosa/api/beans/configurations/BeanConfiguration.kt @@ -18,13 +18,10 @@ import nebulosa.guiding.Guider import nebulosa.guiding.phd2.PHD2Guider import nebulosa.hips2fits.Hips2FitsService import nebulosa.horizons.HorizonsService -import nebulosa.image.Image import nebulosa.log.loggerFor import nebulosa.phd2.client.PHD2Client import nebulosa.sbd.SmallBodyDatabaseService import nebulosa.simbad.SimbadService -import nebulosa.star.detection.StarDetector -import nebulosa.watney.star.detection.WatneyStarDetector import okhttp3.Cache import okhttp3.ConnectionPool import okhttp3.OkHttpClient @@ -159,10 +156,6 @@ class BeanConfiguration { @Bean fun phd2Guider(phd2Client: PHD2Client): Guider = PHD2Guider(phd2Client) - @Bean - @Primary - fun watneyStarDetector(): StarDetector = WatneyStarDetector(computeHFD = true) - @Bean @Primary fun boxStore(dataPath: Path) = MyObjectBox.builder() diff --git a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameEntity.kt b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameEntity.kt index cfc214269..631dc5b9e 100644 --- a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameEntity.kt +++ b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameEntity.kt @@ -1,6 +1,9 @@ package nebulosa.api.calibration -import io.objectbox.annotation.* +import io.objectbox.annotation.Convert +import io.objectbox.annotation.Entity +import io.objectbox.annotation.Id +import io.objectbox.annotation.Index import nebulosa.api.beans.converters.database.FrameTypePropertyConverter import nebulosa.api.beans.converters.database.PathPropertyConverter import nebulosa.api.database.BoxEntity diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureJob.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureJob.kt index 584e66013..652365814 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureJob.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureJob.kt @@ -3,11 +3,11 @@ package nebulosa.api.cameras import nebulosa.api.tasks.Job import nebulosa.indi.device.camera.CameraEvent -data class CameraCaptureJob(override val task: CameraCaptureTask) : Job() { +data class CameraCaptureJob(override val task: CameraCaptureTask) : Job(), CameraEventAware { override val name = "${task.camera.name} Camera Capture Job" - fun handleCameraEvent(event: CameraEvent) { + override fun handleCameraEvent(event: CameraEvent) { task.handleCameraEvent(event) } } diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureTask.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureTask.kt index 79bf64ffc..6d6ade6f0 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureTask.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureTask.kt @@ -24,7 +24,7 @@ data class CameraCaptureTask( private val useFirstExposure: Boolean = false, private val exposureMaxRepeat: Int = 0, private val executor: Executor? = null, -) : AbstractTask(), Consumer { +) : AbstractTask(), Consumer, CameraEventAware { private val delayTask = DelayTask(request.exposureDelay) private val waitForSettleTask = WaitForSettleTask(guider) @@ -58,7 +58,7 @@ data class CameraCaptureTask( } } - fun handleCameraEvent(event: CameraEvent) { + override fun handleCameraEvent(event: CameraEvent) { cameraExposureTask.handleCameraEvent(event) } @@ -67,7 +67,7 @@ data class CameraCaptureTask( cameraExposureTask.reset() - while (!cancellationToken.isDone && + while (!cancellationToken.isCancelled && !cameraExposureTask.isAborted && ((exposureMaxRepeat > 0 && exposureRepeatCount < exposureMaxRepeat) || (exposureMaxRepeat <= 0 && (request.isLoop || exposureCount < request.exposureAmount))) @@ -100,7 +100,7 @@ data class CameraCaptureTask( cameraExposureTask.execute(cancellationToken) // DITHER. - if (!cancellationToken.isDone && !cameraExposureTask.isAborted && guider != null + if (!cancellationToken.isCancelled && !cameraExposureTask.isAborted && guider != null && exposureCount >= 1 && exposureCount % request.dither.afterExposures == 0 ) { ditherAfterExposureTask.execute(cancellationToken) diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraEventAware.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraEventAware.kt new file mode 100644 index 000000000..44acf68fa --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraEventAware.kt @@ -0,0 +1,8 @@ +package nebulosa.api.cameras + +import nebulosa.indi.device.camera.CameraEvent + +fun interface CameraEventAware { + + fun handleCameraEvent(event: CameraEvent) +} diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureTask.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureTask.kt index d7f210a64..018417492 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureTask.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureTask.kt @@ -20,7 +20,7 @@ import kotlin.io.path.outputStream data class CameraExposureTask( @JvmField val camera: Camera, @JvmField val request: CameraStartCaptureRequest, -) : AbstractTask(), CancellationListener { +) : AbstractTask(), CancellationListener, CameraEventAware { private val latch = CountUpDownLatch() private val aborted = AtomicBoolean() @@ -34,7 +34,7 @@ data class CameraExposureTask( val isAborted get() = aborted.get() - fun handleCameraEvent(event: CameraEvent) { + override fun handleCameraEvent(event: CameraEvent) { if (event.device === camera) { when (event) { is CameraFrameCaptured -> { @@ -125,7 +125,7 @@ data class CameraExposureTask( } else if (event.image != null) { savedPath.sink().use(event.image!!::write) } else { - LOG.warn("invalid event. camera={}", event.device) + LOG.warn("invalid event. event={}", event) return } diff --git a/api/src/main/kotlin/nebulosa/api/focusers/AbstractFocuserMoveTask.kt b/api/src/main/kotlin/nebulosa/api/focusers/AbstractFocuserMoveTask.kt new file mode 100644 index 000000000..e130bb6c6 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/focusers/AbstractFocuserMoveTask.kt @@ -0,0 +1,58 @@ +package nebulosa.api.focusers + +import nebulosa.common.concurrency.cancel.CancellationListener +import nebulosa.common.concurrency.cancel.CancellationSource +import nebulosa.common.concurrency.cancel.CancellationToken +import nebulosa.common.concurrency.latch.CountUpDownLatch +import nebulosa.indi.device.focuser.FocuserEvent +import nebulosa.indi.device.focuser.FocuserMoveFailed +import nebulosa.indi.device.focuser.FocuserMovingChanged +import nebulosa.indi.device.focuser.FocuserPositionChanged +import nebulosa.log.loggerFor + +abstract class AbstractFocuserMoveTask : FocuserMoveTask, CancellationListener { + + @JvmField protected val latch = CountUpDownLatch() + + @Volatile private var moving = false + + override fun handleFocuserEvent(event: FocuserEvent) { + if (event.device === focuser) { + when (event) { + is FocuserMovingChanged -> if (event.device.moving) moving = true else latch.reset() + is FocuserPositionChanged -> if (moving && !event.device.moving) latch.reset() + is FocuserMoveFailed -> latch.reset() + } + } + } + + protected abstract fun canMove(): Boolean + + protected abstract fun move() + + override fun execute(cancellationToken: CancellationToken) { + if (!cancellationToken.isCancelled && focuser.connected && !focuser.moving && canMove()) { + try { + cancellationToken.listen(this) + LOG.info("Focuser move started. focuser={}", focuser) + latch.countUp() + move() + latch.await() + } finally { + moving = false + cancellationToken.unlisten(this) + LOG.info("Focuser move finished. focuser={}", focuser) + } + } + } + + override fun onCancel(source: CancellationSource) { + focuser.abortFocus() + latch.reset() + } + + companion object { + + @JvmStatic private val LOG = loggerFor() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/focusers/BacklashCompensation.kt b/api/src/main/kotlin/nebulosa/api/focusers/BacklashCompensation.kt new file mode 100644 index 000000000..e7b4b5982 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/focusers/BacklashCompensation.kt @@ -0,0 +1,13 @@ +package nebulosa.api.focusers + +data class BacklashCompensation( + @JvmField val mode: BacklashCompensationMode = BacklashCompensationMode.OVERSHOOT, + @JvmField val backlashIn: Int = 0, + @JvmField val backlashOut: Int = 0, +) { + + companion object { + + @JvmStatic val EMPTY = BacklashCompensation() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/focusers/BacklashCompensationFocuserMoveTask.kt b/api/src/main/kotlin/nebulosa/api/focusers/BacklashCompensationFocuserMoveTask.kt new file mode 100644 index 000000000..1131b1bd7 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/focusers/BacklashCompensationFocuserMoveTask.kt @@ -0,0 +1,141 @@ +package nebulosa.api.focusers + +import nebulosa.common.concurrency.cancel.CancellationToken +import nebulosa.indi.device.focuser.Focuser +import nebulosa.indi.device.focuser.FocuserEvent +import nebulosa.log.loggerFor + +/** + * This decorator will wrap an absolute backlash [compensation] model around the [focuser]. + * On each move an absolute backlash compensation value will be applied, if the focuser changes its moving direction + * The returned position will then accommodate for this backlash and simulating the position without backlash. + */ +data class BacklashCompensationFocuserMoveTask( + override val focuser: Focuser, + @JvmField @Volatile var position: Int, + @JvmField val compensation: BacklashCompensation, +) : FocuserMoveTask { + + enum class OvershootDirection { + NONE, + IN, + OUT, + } + + @Volatile private var offset = 0 + @Volatile private var lastDirection = OvershootDirection.NONE + + private val task = FocuserMoveAbsoluteTask(focuser, 0) + + /** + * Returns the adjusted position based on the amount of backlash compensation. + */ + val adjustedPosition + get() = focuser.position - offset + + override fun handleFocuserEvent(event: FocuserEvent) { + task.handleFocuserEvent(event) + } + + override fun execute(cancellationToken: CancellationToken) { + if (!cancellationToken.isCancelled && focuser.connected && !focuser.moving) { + val startPosition = focuser.position + + val newPosition = when (compensation.mode) { + BacklashCompensationMode.ABSOLUTE -> { + val adjustedTargetPosition = position + offset + + if (adjustedTargetPosition < 0) { + offset = 0 + 0 + } else if (adjustedTargetPosition > focuser.maxPosition) { + offset = 0 + focuser.maxPosition + } else { + val backlashCompensation = calculateAbsoluteBacklashCompensation(startPosition, adjustedTargetPosition) + offset += backlashCompensation + adjustedTargetPosition + backlashCompensation + } + } + BacklashCompensationMode.OVERSHOOT -> { + val backlashCompensation = calculateOvershootBacklashCompensation(startPosition, position) + + if (backlashCompensation != 0) { + val overshoot = position + backlashCompensation + + if (overshoot < 0) { + LOG.warn("overshooting position is below minimum 0, skipping overshoot") + } else if (overshoot > focuser.maxPosition) { + LOG.warn("overshooting position is above maximum ${focuser.maxPosition}, skipping overshoot") + } else { + LOG.info("overshooting from $startPosition to overshoot position $overshoot using a compensation of $backlashCompensation") + moveFocuser(overshoot, cancellationToken) + LOG.info("moving back to position $position") + } + } + + position + } + else -> { + position + } + } + + LOG.info("moving to position {} using {} backlash compensation", newPosition, compensation.mode) + + moveFocuser(newPosition, cancellationToken) + } + } + + private fun moveFocuser(position: Int, cancellationToken: CancellationToken) { + if (position > 0 && position <= focuser.maxPosition) { + lastDirection = determineMovingDirection(focuser.position, position) + task.position = position + task.execute(cancellationToken) + } + } + + override fun reset() { + task.reset() + + offset = 0 + lastDirection = OvershootDirection.NONE + } + + override fun close() { + task.close() + } + + private fun determineMovingDirection(prevPosition: Int, newPosition: Int): OvershootDirection { + return if (newPosition > prevPosition) OvershootDirection.OUT + else if (newPosition < prevPosition) OvershootDirection.IN + else lastDirection + } + + private fun calculateAbsoluteBacklashCompensation(lastPosition: Int, newPosition: Int): Int { + val direction = determineMovingDirection(lastPosition, newPosition) + + return if (direction == OvershootDirection.IN && lastDirection == OvershootDirection.OUT) { + LOG.info("Focuser is reversing direction from outwards to inwards") + -compensation.backlashIn + } else if (direction == OvershootDirection.OUT && lastDirection === OvershootDirection.IN) { + LOG.info("Focuser is reversing direction from inwards to outwards") + compensation.backlashOut + } else { + 0 + } + } + + private fun calculateOvershootBacklashCompensation(lastPosition: Int, newPosition: Int): Int { + val direction = determineMovingDirection(lastPosition, newPosition) + + return if (direction == OvershootDirection.IN && compensation.backlashIn != 0) -compensation.backlashIn + else if (direction == OvershootDirection.OUT && compensation.backlashOut != 0) compensation.backlashOut + else 0 + } + + companion object { + + @JvmStatic private val LOG = loggerFor() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/focusers/BacklashCompensationMode.kt b/api/src/main/kotlin/nebulosa/api/focusers/BacklashCompensationMode.kt new file mode 100644 index 000000000..d75206b3b --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/focusers/BacklashCompensationMode.kt @@ -0,0 +1,7 @@ +package nebulosa.api.focusers + +enum class BacklashCompensationMode { + NONE, + ABSOLUTE, + OVERSHOOT, +} diff --git a/api/src/main/kotlin/nebulosa/api/focusers/FocuserEventAware.kt b/api/src/main/kotlin/nebulosa/api/focusers/FocuserEventAware.kt new file mode 100644 index 000000000..555b783b5 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/focusers/FocuserEventAware.kt @@ -0,0 +1,8 @@ +package nebulosa.api.focusers + +import nebulosa.indi.device.focuser.FocuserEvent + +fun interface FocuserEventAware { + + fun handleFocuserEvent(event: FocuserEvent) +} diff --git a/api/src/main/kotlin/nebulosa/api/focusers/FocuserMoveAbsoluteTask.kt b/api/src/main/kotlin/nebulosa/api/focusers/FocuserMoveAbsoluteTask.kt new file mode 100644 index 000000000..85379b053 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/focusers/FocuserMoveAbsoluteTask.kt @@ -0,0 +1,18 @@ +package nebulosa.api.focusers + +import nebulosa.indi.device.focuser.Focuser +import kotlin.math.abs + +data class FocuserMoveAbsoluteTask( + override val focuser: Focuser, + @JvmField @Volatile var position: Int, +) : AbstractFocuserMoveTask() { + + override fun canMove() = position != focuser.position && position > 0 && position < focuser.maxPosition + + override fun move() { + if (focuser.canAbsoluteMove) focuser.moveFocusTo(position) + else if (position < focuser.position) focuser.moveFocusIn(abs(position - focuser.position)) + else focuser.moveFocusOut(abs(position - focuser.position)) + } +} diff --git a/api/src/main/kotlin/nebulosa/api/focusers/FocuserMoveRelativeTask.kt b/api/src/main/kotlin/nebulosa/api/focusers/FocuserMoveRelativeTask.kt new file mode 100644 index 000000000..ec4671b3a --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/focusers/FocuserMoveRelativeTask.kt @@ -0,0 +1,18 @@ +package nebulosa.api.focusers + +import nebulosa.indi.device.focuser.Focuser +import kotlin.math.abs + +data class FocuserMoveRelativeTask( + override val focuser: Focuser, + @JvmField val offset: Int, +) : AbstractFocuserMoveTask() { + + override fun canMove() = offset != 0 + + override fun move() { + if (!focuser.canRelativeMove) focuser.moveFocusTo(focuser.position + offset) + else if (offset > 0) focuser.moveFocusOut(offset) + else focuser.moveFocusIn(abs(offset)) + } +} diff --git a/api/src/main/kotlin/nebulosa/api/focusers/FocuserMoveTask.kt b/api/src/main/kotlin/nebulosa/api/focusers/FocuserMoveTask.kt new file mode 100644 index 000000000..729ca77af --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/focusers/FocuserMoveTask.kt @@ -0,0 +1,9 @@ +package nebulosa.api.focusers + +import nebulosa.api.tasks.Task +import nebulosa.indi.device.focuser.Focuser + +interface FocuserMoveTask : Task, FocuserEventAware { + + val focuser: Focuser +} diff --git a/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureTask.kt b/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureTask.kt index fb2de4a50..aa59f6828 100644 --- a/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureTask.kt +++ b/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureTask.kt @@ -27,7 +27,7 @@ data class DitherAfterExposureTask( override fun execute(cancellationToken: CancellationToken) { if (guider != null && guider.canDither && request.enabled && guider.state == GuideState.GUIDING - && !cancellationToken.isDone + && !cancellationToken.isCancelled ) { LOG.info("Dither started. request={}", request) diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseTask.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseTask.kt index 787757139..5bc3f2079 100644 --- a/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseTask.kt +++ b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseTask.kt @@ -24,7 +24,7 @@ data class GuidePulseTask( } override fun execute(cancellationToken: CancellationToken) { - if (!cancellationToken.isDone && guideOutput.pulseGuide(request.duration, request.direction)) { + if (!cancellationToken.isCancelled && guideOutput.pulseGuide(request.duration, request.direction)) { LOG.info("Guide Pulse started. guideOutput={}, duration={}, direction={}", guideOutput, request.duration.toMillis(), request.direction) try { diff --git a/api/src/main/kotlin/nebulosa/api/guiding/WaitForSettleTask.kt b/api/src/main/kotlin/nebulosa/api/guiding/WaitForSettleTask.kt index 1f4aa2435..87006cb6e 100644 --- a/api/src/main/kotlin/nebulosa/api/guiding/WaitForSettleTask.kt +++ b/api/src/main/kotlin/nebulosa/api/guiding/WaitForSettleTask.kt @@ -10,7 +10,7 @@ data class WaitForSettleTask( ) : Task { override fun execute(cancellationToken: CancellationToken) { - if (guider != null && guider.isSettling && !cancellationToken.isDone) { + if (guider != null && guider.isSettling && !cancellationToken.isCancelled) { LOG.info("Wait For Settle started") guider.waitForSettle(cancellationToken) LOG.info("Wait For Settle finished") diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageBucket.kt b/api/src/main/kotlin/nebulosa/api/image/ImageBucket.kt index a9119ede1..7d51daeb3 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageBucket.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageBucket.kt @@ -11,17 +11,23 @@ import kotlin.io.path.extension @Component class ImageBucket { - private val bucket = HashMap>(256) + data class OpenedImage( + @JvmField val image: Image, + @JvmField var solution: PlateSolution? = null, + @JvmField val debayer: Boolean = true, + ) + + private val bucket = HashMap(256) @Synchronized - fun put(path: Path, image: Image, solution: PlateSolution? = null) { - bucket[path] = image to (solution ?: PlateSolution.from(image.header)) + fun put(path: Path, image: Image, solution: PlateSolution? = null, debayer: Boolean = true) { + bucket[path] = OpenedImage(image, solution ?: PlateSolution.from(image.header), debayer) } @Synchronized fun put(path: Path, solution: PlateSolution): Boolean { val item = bucket[path] ?: return false - bucket[path] = item.first to solution + item.solution = solution return true } @@ -29,7 +35,9 @@ class ImageBucket { fun open(path: Path, debayer: Boolean = true, solution: PlateSolution? = null, force: Boolean = false): Image { val openedImage = this[path] - if (openedImage != null && !force) return openedImage.first + if (openedImage != null && !force && debayer == openedImage.debayer) { + return openedImage.image + } val representation = when (path.extension.lowercase()) { "fit", "fits" -> path.fits() @@ -38,7 +46,7 @@ class ImageBucket { } val image = representation.use { Image.open(it, debayer) } - put(path, image, solution) + put(path, image, solution, debayer) return image } @@ -47,7 +55,7 @@ class ImageBucket { bucket.remove(path) } - operator fun get(path: Path): Pair? { + operator fun get(path: Path): OpenedImage? { return bucket[path] } @@ -56,10 +64,10 @@ class ImageBucket { } operator fun contains(image: Image): Boolean { - return bucket.any { it.value.first === image } + return bucket.any { it.value.image === image } } operator fun contains(solution: PlateSolution): Boolean { - return bucket.any { it.value.second === solution } + return bucket.any { it.value.solution === solution } } } diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageController.kt b/api/src/main/kotlin/nebulosa/api/image/ImageController.kt index 63d9a58a6..985da8ff7 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageController.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageController.kt @@ -5,7 +5,6 @@ import jakarta.validation.Valid import nebulosa.api.atlas.Location import nebulosa.api.beans.converters.location.LocationParam import nebulosa.indi.device.camera.Camera -import nebulosa.star.detection.ImageStar import org.hibernate.validator.constraints.Range import org.springframework.http.HttpHeaders import org.springframework.web.bind.annotation.* @@ -54,11 +53,6 @@ class ImageController( return imageService.coordinateInterpolation(path) } - @PutMapping("detect-stars") - fun detectStars(@RequestParam path: Path): List { - return imageService.detectStars(path) - } - @GetMapping("histogram") fun histogram( @RequestParam path: Path, diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageService.kt b/api/src/main/kotlin/nebulosa/api/image/ImageService.kt index 59d7ce7da..ff452a3e2 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageService.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageService.kt @@ -24,8 +24,6 @@ import nebulosa.simbad.SimbadSearch import nebulosa.simbad.SimbadService import nebulosa.skycatalog.ClassificationType import nebulosa.skycatalog.SkyObjectType -import nebulosa.star.detection.ImageStar -import nebulosa.star.detection.StarDetector import nebulosa.time.TimeYMDHMS import nebulosa.time.UTC import nebulosa.wcs.WCS @@ -55,7 +53,6 @@ class ImageService( private val imageBucket: ImageBucket, private val threadPoolTaskExecutor: ThreadPoolTaskExecutor, private val connectionService: ConnectionService, - private val starDetector: StarDetector, ) { private enum class ImageOperation { @@ -94,7 +91,7 @@ class ImageService( stretchParams!!.shadow, stretchParams.highlight, stretchParams.midtone, transformedImage.header.rightAscension.takeIf { it.isFinite() }, transformedImage.header.declination.takeIf { it.isFinite() }, - imageBucket[path]?.second?.let(::ImageSolved), + imageBucket[path]?.solution?.let(::ImageSolved), transformedImage.header.mapNotNull { if (it.isCommentStyle) null else ImageHeaderItem(it.key, it.value) }, transformedImage.header.bitpix, instrument, statistics, ) @@ -271,7 +268,7 @@ class ImageService( } fun saveImageAs(inputPath: Path, save: SaveImage, camera: Camera?) { - val (image) = imageBucket[inputPath]?.first?.transform(save.shouldBeTransformed, save.transformation, ImageOperation.SAVE) + val (image) = imageBucket[inputPath]?.image?.transform(save.shouldBeTransformed, save.transformation, ImageOperation.SAVE) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Image not found") require(save.path != null) @@ -292,8 +289,7 @@ class ImageService( width: Int, height: Int, fov: Angle, rotation: Angle = 0.0, id: String = "CDS/P/DSS2/COLOR", ): Path { - val (image, calibration, path) = framingService - .frame(rightAscension, declination, width, height, fov, rotation, id)!! + val (image, calibration, path) = framingService.frame(rightAscension, declination, width, height, fov, rotation, id)!! imageBucket.put(path, image, calibration) return path } @@ -334,11 +330,6 @@ class ImageService( return CoordinateInterpolation(ma, md, 0, 0, width, height, delta, image.header.observationDate) } - fun detectStars(path: Path): List { - val (image) = imageBucket[path] ?: return emptyList() - return starDetector.detect(image) - } - fun histogram(path: Path, bitLength: Int = 16): IntArray { val (image) = imageBucket[path] ?: return IntArray(0) return image.compute(Histogram(bitLength = bitLength)) diff --git a/api/src/main/kotlin/nebulosa/api/indi/INDIController.kt b/api/src/main/kotlin/nebulosa/api/indi/INDIController.kt index f66afda59..ca889fd60 100644 --- a/api/src/main/kotlin/nebulosa/api/indi/INDIController.kt +++ b/api/src/main/kotlin/nebulosa/api/indi/INDIController.kt @@ -11,6 +11,21 @@ class INDIController( private val indiService: INDIService, ) { + @GetMapping("{device}") + fun device(device: Device): Device { + return device + } + + @PutMapping("{device}/connect") + fun connect(device: Device) { + indiService.connect(device) + } + + @PutMapping("{device}/disconnect") + fun disconnect(device: Device) { + indiService.disconnect(device) + } + @GetMapping("{device}/properties") fun properties(device: Device): Collection> { return indiService.properties(device) diff --git a/api/src/main/kotlin/nebulosa/api/indi/INDIService.kt b/api/src/main/kotlin/nebulosa/api/indi/INDIService.kt index 0fe78fc8c..91f4c5d92 100644 --- a/api/src/main/kotlin/nebulosa/api/indi/INDIService.kt +++ b/api/src/main/kotlin/nebulosa/api/indi/INDIService.kt @@ -18,6 +18,14 @@ class INDIService( indiEventHandler.unregisterDevice(device) } + fun connect(device: Device) { + device.connect() + } + + fun disconnect(device: Device) { + device.disconnect() + } + fun messages(): List { return indiEventHandler.messages() } diff --git a/api/src/main/kotlin/nebulosa/api/mounts/MountEventAware.kt b/api/src/main/kotlin/nebulosa/api/mounts/MountEventAware.kt new file mode 100644 index 000000000..0cc5c0b31 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/mounts/MountEventAware.kt @@ -0,0 +1,8 @@ +package nebulosa.api.mounts + +import nebulosa.indi.device.mount.MountEvent + +fun interface MountEventAware { + + fun handleMountEvent(event: MountEvent) +} diff --git a/api/src/main/kotlin/nebulosa/api/mounts/MountMoveTask.kt b/api/src/main/kotlin/nebulosa/api/mounts/MountMoveTask.kt index 9279f7400..db1546b52 100644 --- a/api/src/main/kotlin/nebulosa/api/mounts/MountMoveTask.kt +++ b/api/src/main/kotlin/nebulosa/api/mounts/MountMoveTask.kt @@ -23,7 +23,7 @@ data class MountMoveTask( } override fun execute(cancellationToken: CancellationToken) { - if (!cancellationToken.isDone && request.duration.toMillis() > 0) { + if (!cancellationToken.isCancelled && request.duration.toMillis() > 0) { mount.slewRates.takeIf { !request.speed.isNullOrBlank() } ?.find { it.name == request.speed } ?.also { mount.slewRate(it) } diff --git a/api/src/main/kotlin/nebulosa/api/mounts/MountService.kt b/api/src/main/kotlin/nebulosa/api/mounts/MountService.kt index 3ac78f22b..b21b85a2c 100644 --- a/api/src/main/kotlin/nebulosa/api/mounts/MountService.kt +++ b/api/src/main/kotlin/nebulosa/api/mounts/MountService.kt @@ -220,7 +220,7 @@ class MountService(private val imageBucket: ImageBucket) { } fun pointMountHere(mount: Mount, path: Path, x: Double, y: Double) { - val calibration = imageBucket[path]?.second ?: return + val calibration = imageBucket[path]?.solution ?: return if (calibration.isNotEmpty() && calibration.solved) { val wcs = WCS(calibration) diff --git a/api/src/main/kotlin/nebulosa/api/mounts/MountSlewTask.kt b/api/src/main/kotlin/nebulosa/api/mounts/MountSlewTask.kt index 01d9dd9d8..9c057a224 100644 --- a/api/src/main/kotlin/nebulosa/api/mounts/MountSlewTask.kt +++ b/api/src/main/kotlin/nebulosa/api/mounts/MountSlewTask.kt @@ -20,7 +20,7 @@ data class MountSlewTask( @JvmField val mount: Mount, @JvmField val rightAscension: Angle, @JvmField val declination: Angle, @JvmField val j2000: Boolean = false, @JvmField val goTo: Boolean = true, -) : Task, CancellationListener { +) : Task, CancellationListener, MountEventAware { private val delayTask = DelayTask(SETTLE_DURATION) private val latch = CountUpDownLatch() @@ -28,7 +28,7 @@ data class MountSlewTask( @Volatile private var initialRA = mount.rightAscension @Volatile private var initialDEC = mount.declination - fun handleMountEvent(event: MountEvent) { + override fun handleMountEvent(event: MountEvent) { if (event.device === mount) { if (event is MountSlewingChanged) { if (!mount.slewing && (mount.rightAscension != initialRA || mount.declination != initialDEC)) { @@ -42,7 +42,7 @@ data class MountSlewTask( } override fun execute(cancellationToken: CancellationToken) { - if (!cancellationToken.isDone && + if (!cancellationToken.isCancelled && mount.connected && !mount.parked && !mount.parking && !mount.slewing && rightAscension.isFinite() && declination.isFinite() && (mount.rightAscension != rightAscension || mount.declination != declination) diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerJob.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerJob.kt index 0eb144303..55b2e7d70 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerJob.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerJob.kt @@ -1,18 +1,20 @@ package nebulosa.api.sequencer +import nebulosa.api.cameras.CameraEventAware import nebulosa.api.tasks.Job +import nebulosa.api.wheels.WheelEventAware import nebulosa.indi.device.camera.CameraEvent import nebulosa.indi.device.filterwheel.FilterWheelEvent -data class SequencerJob(override val task: SequencerTask) : Job() { +data class SequencerJob(override val task: SequencerTask) : Job(), CameraEventAware, WheelEventAware { override val name = "${task.camera.name} Sequencer Job" - fun handleCameraEvent(event: CameraEvent) { + override fun handleCameraEvent(event: CameraEvent) { task.handleCameraEvent(event) } - fun handleFilterWheelEvent(event: FilterWheelEvent) { + override fun handleFilterWheelEvent(event: FilterWheelEvent) { task.handleFilterWheelEvent(event) } } diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerTask.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerTask.kt index f099436eb..4efde90bf 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerTask.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerTask.kt @@ -1,15 +1,13 @@ package nebulosa.api.sequencer import io.reactivex.rxjava3.functions.Consumer -import nebulosa.api.cameras.CameraCaptureEvent -import nebulosa.api.cameras.CameraCaptureState -import nebulosa.api.cameras.CameraCaptureTask -import nebulosa.api.cameras.CameraStartCaptureRequest +import nebulosa.api.cameras.* import nebulosa.api.messages.MessageEvent import nebulosa.api.tasks.AbstractTask import nebulosa.api.tasks.Task import nebulosa.api.tasks.delay.DelayEvent import nebulosa.api.tasks.delay.DelayTask +import nebulosa.api.wheels.WheelEventAware import nebulosa.api.wheels.WheelMoveTask import nebulosa.common.concurrency.cancel.CancellationToken import nebulosa.guiding.Guider @@ -39,7 +37,7 @@ data class SequencerTask( @JvmField val wheel: FilterWheel? = null, @JvmField val focuser: Focuser? = null, private val executor: Executor? = null, -) : AbstractTask(), Consumer { +) : AbstractTask(), Consumer, CameraEventAware, WheelEventAware { private val usedEntries = plan.entries.filter { it.enabled } @@ -108,7 +106,7 @@ data class SequencerTask( } } - fun handleCameraEvent(event: CameraEvent) { + override fun handleCameraEvent(event: CameraEvent) { val task = currentTask.get() if (task is CameraCaptureTask) { @@ -116,7 +114,7 @@ data class SequencerTask( } } - fun handleFilterWheelEvent(event: FilterWheelEvent) { + override fun handleFilterWheelEvent(event: FilterWheelEvent) { val task = currentTask.get() if (task is WheelMoveTask) { @@ -130,7 +128,7 @@ data class SequencerTask( camera.snoop(listOf(mount, wheel, focuser)) for (task in tasks) { - if (cancellationToken.isDone) break + if (cancellationToken.isCancelled) break currentTask.set(task) task.execute(cancellationToken) currentTask.set(null) diff --git a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverController.kt b/api/src/main/kotlin/nebulosa/api/solver/PlateSolverController.kt index 80c9e3d98..f4936f0fa 100644 --- a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverController.kt +++ b/api/src/main/kotlin/nebulosa/api/solver/PlateSolverController.kt @@ -1,11 +1,9 @@ package nebulosa.api.solver +import jakarta.validation.Valid import nebulosa.api.beans.converters.angle.AngleParam import nebulosa.math.Angle -import org.springframework.web.bind.annotation.PutMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* import java.nio.file.Path @RestController @@ -17,10 +15,10 @@ class PlateSolverController( @PutMapping fun solveImage( @RequestParam path: Path, - options: PlateSolverOptions, + @RequestBody @Valid solver: PlateSolverOptions, @RequestParam(required = false, defaultValue = "true") blind: Boolean, @AngleParam(required = false, isHours = true, defaultValue = "0.0") centerRA: Angle, @AngleParam(required = false, defaultValue = "0.0") centerDEC: Angle, @AngleParam(required = false, defaultValue = "4.0") radius: Angle, - ) = plateSolverService.solveImage(options, path, centerRA, centerDEC, if (blind) 0.0 else radius) + ) = plateSolverService.solveImage(solver, path, centerRA, centerDEC, if (blind) 0.0 else radius) } diff --git a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverOptions.kt b/api/src/main/kotlin/nebulosa/api/solver/PlateSolverOptions.kt index 0e3c8be85..1612c43cc 100644 --- a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverOptions.kt +++ b/api/src/main/kotlin/nebulosa/api/solver/PlateSolverOptions.kt @@ -1,5 +1,10 @@ package nebulosa.api.solver +import nebulosa.astap.plate.solving.AstapPlateSolver +import nebulosa.astrometrynet.nova.NovaAstrometryNetService +import nebulosa.astrometrynet.plate.solving.LocalAstrometryNetPlateSolver +import nebulosa.astrometrynet.plate.solving.NovaAstrometryNetPlateSolver +import okhttp3.OkHttpClient import org.hibernate.validator.constraints.time.DurationMax import org.hibernate.validator.constraints.time.DurationMin import org.springframework.boot.convert.DurationUnit @@ -17,8 +22,21 @@ data class PlateSolverOptions( @JvmField val timeout: Duration = Duration.ZERO, ) { + fun get(httpClient: OkHttpClient? = null) = with(this) { + when (type) { + PlateSolverType.ASTAP -> AstapPlateSolver(executablePath!!) + PlateSolverType.ASTROMETRY_NET -> LocalAstrometryNetPlateSolver(executablePath!!) + PlateSolverType.ASTROMETRY_NET_ONLINE -> { + val key = "$apiUrl@$apiKey" + val service = NOVA_ASTROMETRY_NET_CACHE.getOrPut(key) { NovaAstrometryNetService(apiUrl, httpClient) } + NovaAstrometryNetPlateSolver(service, apiKey) + } + } + } + companion object { @JvmStatic val EMPTY = PlateSolverOptions() + @JvmStatic private val NOVA_ASTROMETRY_NET_CACHE = HashMap() } } diff --git a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverService.kt b/api/src/main/kotlin/nebulosa/api/solver/PlateSolverService.kt index 89e0ba376..61f0e0790 100644 --- a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverService.kt +++ b/api/src/main/kotlin/nebulosa/api/solver/PlateSolverService.kt @@ -2,12 +2,7 @@ package nebulosa.api.solver import nebulosa.api.image.ImageBucket import nebulosa.api.image.ImageSolved -import nebulosa.astap.plate.solving.AstapPlateSolver -import nebulosa.astrometrynet.nova.NovaAstrometryNetService -import nebulosa.astrometrynet.plate.solving.LocalAstrometryNetPlateSolver -import nebulosa.astrometrynet.plate.solving.NovaAstrometryNetPlateSolver import nebulosa.math.Angle -import nebulosa.plate.solving.PlateSolver import okhttp3.OkHttpClient import org.springframework.stereotype.Service import java.nio.file.Path @@ -27,29 +22,10 @@ class PlateSolverService( return ImageSolved(calibration) } - fun solverFor(options: PlateSolverOptions): PlateSolver { - return with(options) { - when (type) { - PlateSolverType.ASTAP -> AstapPlateSolver(executablePath!!) - PlateSolverType.ASTROMETRY_NET -> LocalAstrometryNetPlateSolver(executablePath!!) - PlateSolverType.ASTROMETRY_NET_ONLINE -> { - val key = "$apiUrl@$apiKey" - val service = NOVA_ASTROMETRY_NET_CACHE.getOrPut(key) { NovaAstrometryNetService(apiUrl, httpClient) } - NovaAstrometryNetPlateSolver(service, apiKey) - } - } - } - } - @Synchronized fun solve( options: PlateSolverOptions, path: Path, centerRA: Angle = 0.0, centerDEC: Angle = 0.0, radius: Angle = 0.0, - ) = solverFor(options) + ) = options.get(httpClient) .solve(path, null, centerRA, centerDEC, radius, 1, options.timeout.takeIf { it.toSeconds() > 0 }) - - companion object { - - @JvmStatic private val NOVA_ASTROMETRY_NET_CACHE = HashMap() - } } diff --git a/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionController.kt b/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionController.kt new file mode 100644 index 000000000..810c13811 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionController.kt @@ -0,0 +1,18 @@ +package nebulosa.api.stardetection + +import jakarta.validation.Valid +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.* +import java.nio.file.Path + +@Validated +@RestController +@RequestMapping("star-detection") +class StarDetectionController(private val starDetectionService: StarDetectionService) { + + @PutMapping + fun detectStars( + @RequestParam path: Path, + @RequestBody @Valid body: StarDetectionOptions + ) = starDetectionService.detectStars(path, body) +} diff --git a/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionOptions.kt b/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionOptions.kt new file mode 100644 index 000000000..5f8aa4940 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionOptions.kt @@ -0,0 +1,23 @@ +package nebulosa.api.stardetection + +import nebulosa.astap.star.detection.AstapStarDetector +import nebulosa.star.detection.StarDetector +import java.nio.file.Path +import java.time.Duration +import java.util.function.Supplier + +data class StarDetectionOptions( + @JvmField val type: StarDetectorType = StarDetectorType.ASTAP, + @JvmField val executablePath: Path? = null, + @JvmField val timeout: Duration = Duration.ZERO, +) : Supplier> { + + override fun get() = when (type) { + StarDetectorType.ASTAP -> AstapStarDetector(executablePath!!) + } + + companion object { + + @JvmStatic val EMPTY = StarDetectionOptions() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionService.kt b/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionService.kt new file mode 100644 index 000000000..243239456 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionService.kt @@ -0,0 +1,14 @@ +package nebulosa.api.stardetection + +import nebulosa.star.detection.ImageStar +import org.springframework.stereotype.Service +import java.nio.file.Path + +@Service +class StarDetectionService { + + fun detectStars(path: Path, options: StarDetectionOptions): List { + val starDetector = options.get() + return starDetector.detect(path) + } +} diff --git a/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectorType.kt b/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectorType.kt new file mode 100644 index 000000000..31ae2f97c --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectorType.kt @@ -0,0 +1,5 @@ +package nebulosa.api.stardetection + +enum class StarDetectorType { + ASTAP +} diff --git a/api/src/main/kotlin/nebulosa/api/tasks/delay/DelayTask.kt b/api/src/main/kotlin/nebulosa/api/tasks/delay/DelayTask.kt index af20bc2cf..6d3dbe5e4 100644 --- a/api/src/main/kotlin/nebulosa/api/tasks/delay/DelayTask.kt +++ b/api/src/main/kotlin/nebulosa/api/tasks/delay/DelayTask.kt @@ -17,10 +17,10 @@ data class DelayTask( val durationTime = duration.toMillis() var remainingTime = durationTime - if (!cancellationToken.isDone && remainingTime > 0L) { + if (!cancellationToken.isCancelled && remainingTime > 0L) { LOG.info("Delay started. duration={}", remainingTime) - while (!cancellationToken.isDone && remainingTime > 0L) { + while (!cancellationToken.isCancelled && remainingTime > 0L) { val waitTime = minOf(remainingTime, DELAY_INTERVAL) if (waitTime > 0L) { diff --git a/api/src/main/kotlin/nebulosa/api/wheels/WheelEventAware.kt b/api/src/main/kotlin/nebulosa/api/wheels/WheelEventAware.kt new file mode 100644 index 000000000..664c99f1a --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/wheels/WheelEventAware.kt @@ -0,0 +1,8 @@ +package nebulosa.api.wheels + +import nebulosa.indi.device.filterwheel.FilterWheelEvent + +fun interface WheelEventAware { + + fun handleFilterWheelEvent(event: FilterWheelEvent) +} diff --git a/api/src/main/kotlin/nebulosa/api/wheels/WheelMoveTask.kt b/api/src/main/kotlin/nebulosa/api/wheels/WheelMoveTask.kt index 01340b1b9..cc9235d2f 100644 --- a/api/src/main/kotlin/nebulosa/api/wheels/WheelMoveTask.kt +++ b/api/src/main/kotlin/nebulosa/api/wheels/WheelMoveTask.kt @@ -12,13 +12,13 @@ import nebulosa.log.loggerFor data class WheelMoveTask( @JvmField val wheel: FilterWheel, @JvmField val position: Int, -) : Task { +) : Task, WheelEventAware { private val latch = CountUpDownLatch() @Volatile private var initialPosition = wheel.position - fun handleFilterWheelEvent(event: FilterWheelEvent) { + override fun handleFilterWheelEvent(event: FilterWheelEvent) { if (event is FilterWheelPositionChanged) { if (initialPosition != wheel.position && wheel.position == position) { latch.reset() diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardJob.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardJob.kt index 05c21acc6..4dfcb7a3a 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardJob.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardJob.kt @@ -1,13 +1,14 @@ package nebulosa.api.wizard.flat +import nebulosa.api.cameras.CameraEventAware import nebulosa.api.tasks.Job import nebulosa.indi.device.camera.CameraEvent -data class FlatWizardJob(override val task: FlatWizardTask) : Job() { +data class FlatWizardJob(override val task: FlatWizardTask) : Job(), CameraEventAware { override val name = "${task.camera.name} Flat Wizard Job" - fun handleCameraEvent(event: CameraEvent) { + override fun handleCameraEvent(event: CameraEvent) { task.handleCameraEvent(event) } } diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardTask.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardTask.kt index 865bed152..8a0a0ec3d 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardTask.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardTask.kt @@ -1,9 +1,6 @@ package nebulosa.api.wizard.flat -import nebulosa.api.cameras.AutoSubFolderMode -import nebulosa.api.cameras.CameraCaptureEvent -import nebulosa.api.cameras.CameraCaptureState -import nebulosa.api.cameras.CameraCaptureTask +import nebulosa.api.cameras.* import nebulosa.api.messages.MessageEvent import nebulosa.api.tasks.AbstractTask import nebulosa.common.concurrency.cancel.CancellationToken @@ -20,7 +17,7 @@ import java.time.Duration data class FlatWizardTask( @JvmField val camera: Camera, @JvmField val request: FlatWizardRequest, -) : AbstractTask() { +) : AbstractTask(), CameraEventAware { private val meanTarget = request.meanTarget / 65535f private val meanRange = (meanTarget * request.meanTolerance / 100f).let { (meanTarget - it)..(meanTarget + it) } @@ -34,14 +31,14 @@ data class FlatWizardTask( @Volatile private var capture: CameraCaptureEvent? = null @Volatile private var savedPath: Path? = null - fun handleCameraEvent(event: CameraEvent) { + override fun handleCameraEvent(event: CameraEvent) { cameraCaptureTask?.handleCameraEvent(event) } override fun canUseAsLastEvent(event: MessageEvent) = event is FlatWizardEvent override fun execute(cancellationToken: CancellationToken) { - while (!cancellationToken.isDone) { + while (!cancellationToken.isCancelled) { val delta = exposureMax.toMillis() - exposureMin.toMillis() if (delta < 10) { @@ -78,7 +75,7 @@ data class FlatWizardTask( it.execute(cancellationToken) } - if (cancellationToken.isDone) { + if (cancellationToken.isCancelled) { state = FlatWizardState.IDLE break } else if (savedPath == null) { @@ -106,7 +103,7 @@ data class FlatWizardTask( } } - if (state != FlatWizardState.FAILED && cancellationToken.isDone) { + if (state != FlatWizardState.FAILED && cancellationToken.isCancelled) { state = FlatWizardState.IDLE } diff --git a/api/src/test/kotlin/APITest.kt b/api/src/test/kotlin/APITest.kt index 673abd1cb..a71b9c688 100644 --- a/api/src/test/kotlin/APITest.kt +++ b/api/src/test/kotlin/APITest.kt @@ -4,7 +4,12 @@ import com.fasterxml.jackson.module.kotlin.kotlinModule import io.kotest.core.annotation.EnabledIf import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.booleans.shouldBeTrue +import kotlinx.coroutines.delay +import nebulosa.api.autofocus.AutoFocusRequest +import nebulosa.api.beans.converters.time.DurationSerializer import nebulosa.api.cameras.CameraStartCaptureRequest +import nebulosa.api.connection.ConnectionType +import nebulosa.api.stardetection.StarDetectionOptions import nebulosa.common.json.PathSerializer import nebulosa.test.NonGitHubOnlyCondition import okhttp3.MediaType.Companion.toMediaType @@ -20,40 +25,169 @@ import java.time.Duration class APITest : StringSpec() { init { - "Connect" { put("connection?host=localhost&port=7624") } - "Cameras" { get("cameras") } - "Camera Connect" { put("cameras/$CAMERA_NAME/connect") } - "Camera" { get("cameras/$CAMERA_NAME") } - "Camera Capture Start" { putJson("cameras/$CAMERA_NAME/capture/start", CAMERA_START_CAPTURE_REQUEST) } - "Camera Capture Stop" { put("cameras/$CAMERA_NAME/capture/abort") } - "Camera Disconnect" { put("cameras/$CAMERA_NAME/disconnect") } - "Mounts" { get("mounts") } - "Mount Connect" { put("mounts/$MOUNT_NAME/connect") } - "Mount" { get("mounts/$MOUNT_NAME") } - "Mount Telescope Control Start" { put("mounts/$MOUNT_NAME/remote-control/start?type=LX200&host=0.0.0.0&port=10001") } - "Mount Telescope Control List" { get("mounts/$MOUNT_NAME/remote-control") } - "Mount Telescope Control Stop" { put("mounts/$MOUNT_NAME/remote-control/stop?type=LX200") } - "Mount Disconnect" { put("mounts/$MOUNT_NAME/disconnect") } - "Disconnect" { delete("connection") } + // GENERAL. + + "Connect" { connect() } + "Disconnect" { disconnect() } + + // CAMERA. + + "Cameras" { cameras() } + "Camera Connect" { cameraConnect() } + "Camera" { camera() } + "Camera Capture Start" { cameraStartCapture() } + "Camera Capture Stop" { cameraStopCapture() } + "Camera Disconnect" { cameraDisconnect() } + + // MOUNT. + + "Mounts" { mounts() } + "Mount Connect" { mountConnect() } + "Mount" { mount() } + "Mount Remote Control Start" { mountRemoteControlStart() } + "Mount Remote Control List" { mountRemoteControlList() } + "Mount Remote Control Stop" { mountRemoteControlStop() } + "Mount Disconnect" { mountDisconnect() } + + // FOCUSER. + + "Focusers" { focusers() } + "Focuser Connect" { focuserConnect() } + "Focuser" { focuser() } + "Focuser Disconnect" { focuserDisconnect() } + + // AUTO FOCUS. + + "Auto Focus Start" { + connect("192.168.31.153", 11111, ConnectionType.ALPACA) + delay(1000) + cameraConnect() + focuserConnect() + delay(1000) + // focuserMoveTo(position = 8100) + delay(2000) + autoFocusStart() + } + "Auto Focus Stop" { autoFocusStop() } + } + + private fun connect(host: String = "0.0.0.0", port: Int = 7624, type: ConnectionType = ConnectionType.INDI) { + put("connection?host=$host&port=$port&type=$type") + } + + private fun disconnect() { + delete("connection") + } + + private fun cameras() { + get("cameras") + } + + private fun cameraConnect(camera: String = CAMERA_NAME) { + put("cameras/$camera/connect") + } + + private fun cameraDisconnect(camera: String = CAMERA_NAME) { + put("cameras/$camera/disconnect") + } + + private fun camera(camera: String = CAMERA_NAME) { + get("cameras/$camera") + } + + private fun cameraStartCapture(camera: String = CAMERA_NAME) { + putJson("cameras/$camera/capture/start", CAMERA_START_CAPTURE_REQUEST) + } + + private fun cameraStopCapture(camera: String = CAMERA_NAME) { + put("cameras/$camera/capture/abort") + } + + private fun mounts() { + get("mounts") + } + + private fun mountConnect(mount: String = MOUNT_NAME) { + put("mounts/$mount/connect") + } + + private fun mountDisconnect(mount: String = MOUNT_NAME) { + put("mounts/$mount/disconnect") + } + + private fun mount(mount: String = MOUNT_NAME) { + get("mounts/$mount") + } + + private fun mountRemoteControlStart(mount: String = MOUNT_NAME, host: String = "0.0.0.0", port: Int = 10001) { + put("mounts/$mount/remote-control/start?type=LX200&host=$host&port=$port") + } + + private fun mountRemoteControlList(mount: String = MOUNT_NAME) { + get("mounts/$mount/remote-control") + } + + private fun mountRemoteControlStop(mount: String = MOUNT_NAME) { + put("mounts/$mount/remote-control/stop?type=LX200") + } + + private fun focusers() { + get("focusers") + } + + private fun focuserConnect(focuser: String = FOCUSER_NAME) { + put("focusers/$focuser/connect") + } + + private fun focuserDisconnect(focuser: String = FOCUSER_NAME) { + put("focusers/$focuser/disconnect") + } + + private fun focuser(focuser: String = FOCUSER_NAME) { + get("focusers/$focuser") + } + + private fun focuserMoveTo(focuser: String = FOCUSER_NAME, position: Int) { + put("focusers/$focuser/move-to?steps=$position") + } + + private fun autoFocusStart(camera: String = CAMERA_NAME, focuser: String = FOCUSER_NAME) { + putJson("auto-focus/$camera/$focuser/start", AUTO_FOCUS_REQUEST) + } + + private fun autoFocusStop(camera: String = CAMERA_NAME) { + put("auto-focus/$camera/stop") } companion object { private const val BASE_URL = "http://localhost:7000" - private const val CAMERA_NAME = "CCD Simulator" + private const val CAMERA_NAME = "Sky Simulator" private const val MOUNT_NAME = "Telescope Simulator" + private const val FOCUSER_NAME = "ZWO Focuser (1)" @JvmStatic private val EXPOSURE_TIME = Duration.ofSeconds(5) @JvmStatic private val CAPTURES_PATH = Path.of("/home/tiagohm/Git/nebulosa/data/captures") - @JvmStatic private val CAMERA_START_CAPTURE_REQUEST = - CameraStartCaptureRequest(exposureTime = EXPOSURE_TIME, width = 1280, height = 1024, frameFormat = "INDI_MONO", savePath = CAPTURES_PATH) - .copy(exposureAmount = 2) + + @JvmStatic private val STAR_DETECTION_OPTIONS = StarDetectionOptions(executablePath = Path.of("astap")) + + @JvmStatic private val CAMERA_START_CAPTURE_REQUEST = CameraStartCaptureRequest( + exposureTime = EXPOSURE_TIME, width = 1280, height = 1024, frameFormat = "INDI_MONO", + savePath = CAPTURES_PATH, exposureAmount = 1 + ) + + @JvmStatic private val AUTO_FOCUS_REQUEST = AutoFocusRequest( + capture = CAMERA_START_CAPTURE_REQUEST, stepSize = 500, + starDetector = STAR_DETECTION_OPTIONS + ) @JvmStatic private val CLIENT = OkHttpClient.Builder() .addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) .build() - @JvmStatic private val KOTLIN_MODULE = kotlinModule().addSerializer(PathSerializer) + @JvmStatic private val KOTLIN_MODULE = kotlinModule() + .addSerializer(PathSerializer) + .addSerializer(DurationSerializer()) @JvmStatic private val OBJECT_MAPPER = ObjectMapper() .registerModule(JavaTimeModule()) @@ -78,8 +212,7 @@ class APITest : StringSpec() { private fun putJson(path: String, data: Any) { val bytes = OBJECT_MAPPER.writeValueAsBytes(data) val body = bytes.toRequestBody(APPLICATION_JSON) - val request = Request.Builder().put(body).url("$BASE_URL/$path").build() - CLIENT.newCall(request).execute().use { it.isSuccessful.shouldBeTrue() } + put(path, body) } @JvmStatic diff --git a/desktop/README.md b/desktop/README.md index b6880a99a..2f0bae907 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -50,6 +50,10 @@ The complete integrated solution for all of your astronomical imaging needs. ![](flat-wizard.png) +## Auto Focus + +![](auto-focus.png) + ## Sequencer ![](sequencer.png) diff --git a/desktop/app/main.ts b/desktop/app/main.ts index f631e27e1..2dada076b 100644 --- a/desktop/app/main.ts +++ b/desktop/app/main.ts @@ -565,6 +565,6 @@ function sendToAllWindows(channel: string, data: any, home: boolean = true) { } if (serve) { - console.info(data) + console.info(JSON.stringify(data)) } } diff --git a/desktop/auto-focus.png b/desktop/auto-focus.png new file mode 100644 index 000000000..cea2e19e3 Binary files /dev/null and b/desktop/auto-focus.png differ diff --git a/desktop/src/app/alignment/alignment.component.html b/desktop/src/app/alignment/alignment.component.html index d57ed21c1..3acb4adf8 100644 --- a/desktop/src/app/alignment/alignment.component.html +++ b/desktop/src/app/alignment/alignment.component.html @@ -1,22 +1,14 @@
- + @if (tab === 0) { - - - + } @else { - - - + }
@@ -35,8 +27,8 @@
- +
diff --git a/desktop/src/app/alignment/alignment.component.ts b/desktop/src/app/alignment/alignment.component.ts index fe628cd54..25bbae873 100644 --- a/desktop/src/app/alignment/alignment.component.ts +++ b/desktop/src/app/alignment/alignment.component.ts @@ -9,7 +9,7 @@ import { Angle } from '../../shared/types/atlas.types' import { Camera, EMPTY_CAMERA, EMPTY_CAMERA_START_CAPTURE, ExposureTimeUnit, updateCameraStartCaptureFromCamera } from '../../shared/types/camera.types' import { EMPTY_GUIDE_OUTPUT, GuideDirection, GuideOutput } from '../../shared/types/guider.types' import { EMPTY_MOUNT, Mount } from '../../shared/types/mount.types' -import { DEFAULT_SOLVER_TYPES, EMPTY_PLATE_SOLVER_PREFERENCE } from '../../shared/types/settings.types' +import { EMPTY_PLATE_SOLVER_OPTIONS } from '../../shared/types/settings.types' import { deviceComparator } from '../../shared/utils/comparators' import { AppComponent } from '../app.component' import { CameraComponent } from '../camera/camera.component' @@ -39,7 +39,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { readonly tppaRequest: TPPAStart = { capture: structuredClone(EMPTY_CAMERA_START_CAPTURE), - plateSolver: structuredClone(EMPTY_PLATE_SOLVER_PREFERENCE), + plateSolver: structuredClone(EMPTY_PLATE_SOLVER_OPTIONS), startFromCurrentPosition: true, stepDirection: 'EAST', compensateRefraction: true, @@ -47,7 +47,6 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { stepDuration: 5, } - readonly plateSolverTypes = Array.from(DEFAULT_SOLVER_TYPES) tppaFailed = false tppaRightAscension: Angle = `00h00m00s` tppaDeclination: Angle = `00°00'00"` @@ -109,7 +108,6 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { } this.cameras.splice(index, 1) - this.cameras.sort(deviceComparator) } }) }) @@ -139,7 +137,6 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { } this.mounts.splice(index, 1) - this.mounts.sort(deviceComparator) } }) }) @@ -169,7 +166,6 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { } this.guideOutputs.splice(index, 1) - this.guideOutputs.sort(deviceComparator) } }) }) @@ -265,26 +261,6 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { } } - mountConnect() { - if (this.mount.id) { - if (this.mount.connected) { - this.api.mountDisconnect(this.mount) - } else { - this.api.mountConnect(this.mount) - } - } - } - - guideOutputConnect() { - if (this.guideOutput.id) { - if (this.guideOutput.connected) { - this.api.guideOutputDisconnect(this.guideOutput) - } else { - this.api.guideOutputConnect(this.guideOutput) - } - } - } - async showCameraDialog() { if (this.camera.id) { if (this.tab === 0) { @@ -303,7 +279,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { } plateSolverChanged() { - this.tppaRequest.plateSolver = this.preference.plateSolverPreference(this.tppaRequest.plateSolver.type).get() + this.tppaRequest.plateSolver = this.preference.plateSolverOptions(this.tppaRequest.plateSolver.type).get() this.savePreference() } @@ -350,7 +326,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { } openCameraImage() { - return this.browserWindow.openCameraImage(this.camera) + return this.browserWindow.openCameraImage(this.camera, 'ALIGNMENT') } private loadPreference() { diff --git a/desktop/src/app/app-routing.module.ts b/desktop/src/app/app-routing.module.ts index bdd1b6a47..419218d23 100644 --- a/desktop/src/app/app-routing.module.ts +++ b/desktop/src/app/app-routing.module.ts @@ -4,6 +4,7 @@ import { APP_CONFIG } from '../environments/environment' import { AboutComponent } from './about/about.component' import { AlignmentComponent } from './alignment/alignment.component' import { AtlasComponent } from './atlas/atlas.component' +import { AutoFocusComponent } from './autofocus/autofocus.component' import { CalculatorComponent } from './calculator/calculator.component' import { CalibrationComponent } from './calibration/calibration.component' import { CameraComponent } from './camera/camera.component' @@ -86,6 +87,10 @@ const routes: Routes = [ path: 'calibration', component: CalibrationComponent, }, + { + path: 'auto-focus', + component: AutoFocusComponent, + }, { path: 'calculator', component: CalculatorComponent, diff --git a/desktop/src/app/app.component.ts b/desktop/src/app/app.component.ts index a9058300f..ba2f53ea7 100644 --- a/desktop/src/app/app.component.ts +++ b/desktop/src/app/app.component.ts @@ -1,14 +1,10 @@ import { AfterViewInit, Component } from '@angular/core' import { Title } from '@angular/platform-browser' import { ActivatedRoute } from '@angular/router' -import { MenuItem } from 'primeng/api' import { APP_CONFIG } from '../environments/environment' +import { MenuItem } from '../shared/components/menu-item/menu-item.component' import { ElectronService } from '../shared/services/electron.service' -export interface ExtendedMenuItem extends MenuItem { - badgeSeverity?: 'success' | 'info' | 'warning' | 'danger' -} - @Component({ selector: 'app-root', templateUrl: './app.component.html', @@ -21,7 +17,7 @@ export class AppComponent implements AfterViewInit { readonly modal = window.options.modal ?? false subTitle? = '' backgroundColor = '#212121' - topMenu: ExtendedMenuItem[] = [] + topMenu: MenuItem[] = [] showTopBar = true get title() { diff --git a/desktop/src/app/app.module.ts b/desktop/src/app/app.module.ts index 68543be5d..dd8e85990 100644 --- a/desktop/src/app/app.module.ts +++ b/desktop/src/app/app.module.ts @@ -42,7 +42,7 @@ import { ToastModule } from 'primeng/toast' import { TooltipModule } from 'primeng/tooltip' import { TreeModule } from 'primeng/tree' import { CameraExposureComponent } from '../shared/components/camera-exposure/camera-exposure.component' -import { DeviceListButtonComponent } from '../shared/components/device-list-button/device-list-button.component' +import { DeviceChooserComponent } from '../shared/components/device-chooser/device-chooser.component' import { DeviceListMenuComponent } from '../shared/components/device-list-menu/device-list-menu.component' import { DialogMenuComponent } from '../shared/components/dialog-menu/dialog-menu.component' import { HistogramComponent } from '../shared/components/histogram/histogram.component' @@ -66,6 +66,7 @@ import { AlignmentComponent } from './alignment/alignment.component' import { AppRoutingModule } from './app-routing.module' import { AppComponent } from './app.component' import { AtlasComponent } from './atlas/atlas.component' +import { AutoFocusComponent } from './autofocus/autofocus.component' import { CalculatorComponent } from './calculator/calculator.component' import { FormulaComponent } from './calculator/formula/formula.component' import { CalibrationComponent } from './calibration/calibration.component' @@ -91,11 +92,12 @@ import { SettingsComponent } from './settings/settings.component' AnglePipe, AppComponent, AtlasComponent, + AutoFocusComponent, CalculatorComponent, CalibrationComponent, CameraComponent, CameraExposureComponent, - DeviceListButtonComponent, + DeviceChooserComponent, DeviceListMenuComponent, DialogMenuComponent, EnumPipe, diff --git a/desktop/src/app/atlas/atlas.component.html b/desktop/src/app/atlas/atlas.component.html index 6bbcf5f94..db73c7c19 100644 --- a/desktop/src/app/atlas/atlas.component.html +++ b/desktop/src/app/atlas/atlas.component.html @@ -59,7 +59,7 @@
- @@ -140,7 +140,7 @@
- +
@@ -182,7 +182,7 @@
- +
@@ -228,74 +228,74 @@
- +
- +
- +
- +
- +
- +
-
- +
- +
- +
- +
- +
diff --git a/desktop/src/app/atlas/atlas.component.ts b/desktop/src/app/atlas/atlas.component.ts index 4e95d9c85..2397b406d 100644 --- a/desktop/src/app/atlas/atlas.component.ts +++ b/desktop/src/app/atlas/atlas.component.ts @@ -8,7 +8,7 @@ import { ListboxChangeEvent } from 'primeng/listbox' import { OverlayPanel } from 'primeng/overlaypanel' import { Subscription, timer } from 'rxjs' import { DeviceListMenuComponent } from '../../shared/components/device-list-menu/device-list-menu.component' -import { ExtendedMenuItem } from '../../shared/components/menu-item/menu-item.component' +import { MenuItem } from '../../shared/components/menu-item/menu-item.component' import { ONE_DECIMAL_PLACE_FORMATTER, TWO_DIGITS_FORMATTER } from '../../shared/constants' import { SkyObjectPipe } from '../../shared/pipes/skyObject.pipe' import { ApiService } from '../../shared/services/api.service' @@ -406,7 +406,7 @@ export class AtlasComponent implements OnInit, AfterContentInit, AfterViewInit, 'ONEWEB', 'SCIENCE', 'STARLINK', 'STATIONS', 'VISUAL' ] - readonly ephemerisModel: ExtendedMenuItem[] = [ + readonly ephemerisModel: MenuItem[] = [ { icon: 'mdi mdi-magnify', label: 'Find sky objects around this object', diff --git a/desktop/src/app/autofocus/autofocus.component.html b/desktop/src/app/autofocus/autofocus.component.html new file mode 100644 index 000000000..2e39c859f --- /dev/null +++ b/desktop/src/app/autofocus/autofocus.component.html @@ -0,0 +1,129 @@ +
+
+ + + +
+
+ +
+
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+
+ + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+
+
+
+ +
+ +
+
+
+
+
+ + + +
+
\ No newline at end of file diff --git a/desktop/src/shared/components/device-list-button/device-list-button.component.scss b/desktop/src/app/autofocus/autofocus.component.scss similarity index 100% rename from desktop/src/shared/components/device-list-button/device-list-button.component.scss rename to desktop/src/app/autofocus/autofocus.component.scss diff --git a/desktop/src/app/autofocus/autofocus.component.ts b/desktop/src/app/autofocus/autofocus.component.ts new file mode 100644 index 000000000..e95c56c50 --- /dev/null +++ b/desktop/src/app/autofocus/autofocus.component.ts @@ -0,0 +1,439 @@ +import { AfterViewInit, Component, HostListener, NgZone, OnDestroy, ViewChild } from '@angular/core' +import { ChartData, ChartOptions } from 'chart.js' +import { Point } from 'electron' +import { UIChart } from 'primeng/chart' +import { CameraExposureComponent } from '../../shared/components/camera-exposure/camera-exposure.component' +import { ApiService } from '../../shared/services/api.service' +import { BrowserWindowService } from '../../shared/services/browser-window.service' +import { ElectronService } from '../../shared/services/electron.service' +import { PreferenceService } from '../../shared/services/preference.service' +import { AutoFocusPreference, AutoFocusRequest, AutoFocusState, CurveChart, EMPTY_AUTO_FOCUS_PREFERENCE } from '../../shared/types/autofocus.type' +import { Camera, EMPTY_CAMERA, EMPTY_CAMERA_START_CAPTURE, updateCameraStartCaptureFromCamera } from '../../shared/types/camera.types' +import { EMPTY_FOCUSER, Focuser } from '../../shared/types/focuser.types' +import { deviceComparator } from '../../shared/utils/comparators' +import { AppComponent } from '../app.component' +import { CameraComponent } from '../camera/camera.component' + +@Component({ + selector: 'app-autofocus', + templateUrl: './autofocus.component.html', + styleUrls: ['./autofocus.component.scss'], +}) +export class AutoFocusComponent implements AfterViewInit, OnDestroy { + + cameras: Camera[] = [] + camera = structuredClone(EMPTY_CAMERA) + + focusers: Focuser[] = [] + focuser = structuredClone(EMPTY_FOCUSER) + + running = false + status: AutoFocusState = 'IDLE' + starCount = 0 + starHFD = 0 + focusPoints: Point[] = [] + + private stepSizeForScale = 0 + + readonly request: AutoFocusRequest = { + ...structuredClone(EMPTY_AUTO_FOCUS_PREFERENCE), + capture: structuredClone(EMPTY_CAMERA_START_CAPTURE), + } + + @ViewChild('cameraExposure') + private readonly cameraExposure!: CameraExposureComponent + + @ViewChild('chart') + private readonly chart!: UIChart + + readonly chartOptions: ChartOptions = { + responsive: true, + plugins: { + legend: { + display: false, + }, + tooltip: { + displayColors: false, + intersect: false, + filter: (item) => { + return Math.abs(item.parsed.y) - 0.1 > 0.0 + }, + callbacks: { + title: (item) => { + return `${item[0].parsed.x.toFixed(0)}` + }, + label: (item) => { + return `${item.parsed.y.toFixed(1)}` + } + } + }, + zoom: { + zoom: { + wheel: { + enabled: true, + }, + pinch: { + enabled: false, + }, + mode: 'x', + scaleMode: 'xy', + }, + pan: { + enabled: true, + mode: 'xy', + }, + limits: { + x: { + min: 0, + max: 100, + }, + y: { + min: 0, + max: 20, + }, + } + }, + }, + scales: { + y: { + stacked: false, + beginAtZero: true, + min: 0, + max: 20, + ticks: { + autoSkip: true, + count: 5, + callback: (value) => { + return (value as number).toFixed(1).padStart(2, ' ') + } + }, + border: { + display: true, + dash: [2, 4], + }, + grid: { + display: true, + drawTicks: false, + drawOnChartArea: true, + color: '#212121', + } + }, + x: { + type: 'linear', + stacked: false, + min: 0, + max: 100, + border: { + display: true, + dash: [2, 4], + }, + ticks: { + autoSkip: true, + count: 11, + maxRotation: 0, + minRotation: 0, + }, + grid: { + display: true, + drawTicks: false, + color: '#212121', + } + } + } + } + + readonly chartData: ChartData = { + datasets: [ + // TREND LINE (LEFT). + { + type: 'line', + fill: false, + borderColor: '#F44336', + borderWidth: 1, + data: [], + pointRadius: 0, + pointHitRadius: 0, + }, + // TREND LINE (RIGHT). + { + type: 'line', + fill: false, + borderColor: '#F44336', + borderWidth: 1, + data: [], + pointRadius: 0, + pointHitRadius: 0, + }, + // PARABOLIC. + { + type: 'line', + fill: false, + borderColor: '#03A9F4', + borderWidth: 1, + data: [], + pointRadius: 0, + pointHitRadius: 0, + }, + // HYPERBOLIC. + { + type: 'line', + tension: 1, + fill: false, + borderColor: '#4CAF50', + borderWidth: 1, + data: [], + pointRadius: 0, + pointHitRadius: 0, + }, + // FOCUS POINTS. + { + type: 'scatter', + fill: false, + borderColor: '#7E57C2', + borderWidth: 1, + data: [], + pointRadius: 2, + pointHitRadius: 2, + }, + // PREDICTED FOCUS POINT. + { + type: 'scatter', + backgroundColor: '#FFA726', + borderColor: '#FFA726', + borderWidth: 1, + data: [], + pointRadius: 4, + pointHitRadius: 4, + pointStyle: 'cross', + }, + ] + } + + constructor( + app: AppComponent, + private api: ApiService, + private browserWindow: BrowserWindowService, + private preference: PreferenceService, + electron: ElectronService, + ngZone: NgZone, + ) { + app.title = 'Auto Focus' + + electron.on('CAMERA.UPDATED', event => { + if (event.device.id === this.camera.id) { + ngZone.run(() => { + Object.assign(this.camera, event.device) + }) + } + }) + + electron.on('CAMERA.ATTACHED', event => { + ngZone.run(() => { + this.cameras.push(event.device) + this.cameras.sort(deviceComparator) + }) + }) + + electron.on('CAMERA.DETACHED', event => { + ngZone.run(() => { + const index = this.cameras.findIndex(e => e.id === event.device.id) + + if (index >= 0) { + if (this.cameras[index] === this.camera) { + Object.assign(this.camera, this.cameras[0] ?? EMPTY_CAMERA) + } + + this.cameras.splice(index, 1) + } + }) + }) + + electron.on('FOCUSER.UPDATED', event => { + if (event.device.id === this.focuser.id) { + ngZone.run(() => { + Object.assign(this.focuser, event.device) + }) + } + }) + + electron.on('FOCUSER.ATTACHED', event => { + ngZone.run(() => { + this.focusers.push(event.device) + this.focusers.sort(deviceComparator) + }) + }) + + electron.on('FOCUSER.DETACHED', event => { + ngZone.run(() => { + const index = this.focusers.findIndex(e => e.id === event.device.id) + + if (index >= 0) { + if (this.focusers[index] === this.focuser) { + Object.assign(this.focuser, this.focusers[0] ?? EMPTY_FOCUSER) + } + + this.focusers.splice(index, 1) + } + }) + }) + + electron.on('AUTO_FOCUS.ELAPSED', event => { + ngZone.run(() => { + this.status = event.state + this.running = event.state !== 'FAILED' && event.state !== 'FINISHED' + + if (event.capture) { + this.cameraExposure.handleCameraCaptureEvent(event.capture, true) + } + + if (event.state === 'CURVE_FITTED') { + this.focusPoints.push(event.focusPoint!) + } else if (event.state === 'ANALYSED') { + this.starCount = event.starCount + this.starHFD = event.starHFD + } + + if (event.chart) { + this.updateChart(event.chart) + } + }) + }) + + this.loadPreference() + } + + async ngAfterViewInit() { + this.cameras = (await this.api.cameras()).sort(deviceComparator) + this.focusers = (await this.api.focusers()).sort(deviceComparator) + } + + @HostListener('window:unload') + async ngOnDestroy() { + await this.stop() + } + + async cameraChanged() { + if (this.camera.id) { + const camera = await this.api.camera(this.camera.id) + Object.assign(this.camera, camera) + this.loadPreference() + } + } + + async focuserChanged() { + if (this.focuser.id) { + const focuser = await this.api.focuser(this.focuser.id) + Object.assign(this.focuser, focuser) + } + } + + async showCameraDialog() { + if (this.camera.id) { + if (await CameraComponent.showAsDialog(this.browserWindow, 'AUTO_FOCUS', this.camera, this.request.capture)) { + this.savePreference() + } + } + } + + async start() { + await this.openCameraImage() + + this.clearChart() + this.stepSizeForScale = this.request.stepSize + + this.request.starDetector = this.preference.starDetectionOptions('ASTAP').get() + return this.api.autoFocusStart(this.camera, this.focuser, this.request) + } + + stop() { + return this.api.autoFocusStop(this.camera) + } + + openCameraImage() { + return this.browserWindow.openCameraImage(this.camera, 'ALIGNMENT') + } + + private updateChart(data: CurveChart) { + if (data.trendLine) { + this.chartData.datasets[0].data = data.trendLine.left.points + this.chartData.datasets[1].data = data.trendLine.right.points + } else { + this.chartData.datasets[0].data = [] + this.chartData.datasets[1].data = [] + } + + if (data.parabolic) { + this.chartData.datasets[2].data = data.parabolic.points + } else { + this.chartData.datasets[2].data = [] + } + + if (data.hyperbolic) { + this.chartData.datasets[3].data = data.hyperbolic.points + } else { + this.chartData.datasets[3].data = [] + } + + this.chartData.datasets[4].data = this.focusPoints + + if (data.predictedFocusPoint) { + this.chartData.datasets[5].data = [data.predictedFocusPoint] + } else { + this.chartData.datasets[5].data = [] + } + + const scales = this.chartOptions.scales! + scales.x!.min = Math.max(0, data.minX - this.stepSizeForScale) + scales.x!.max = data.maxX + this.stepSizeForScale + scales.y!.max = (data.maxY || 19) + 1 + + const zoom = this.chartOptions.plugins!.zoom! + zoom.limits!.x!.min = scales.x!.min + zoom.limits!.x!.max = scales.x!.max + zoom.limits!.y!.max = scales.y!.max + + this.chart?.refresh() + } + + private clearChart() { + this.focusPoints = [] + + for (let i = 0; i < this.chartData.datasets.length; i++) { + this.chartData.datasets[i].data = [] + } + + this.chart?.refresh() + } + + private loadPreference() { + const preference = this.preference.autoFocusPreference.get() + + this.request.fittingMode = preference.fittingMode ?? 'HYPERBOLIC' + this.request.initialOffsetSteps = preference.initialOffsetSteps ?? 4 + this.request.rSquaredThreshold = preference.rSquaredThreshold ?? 0.5 + this.request.stepSize = preference.stepSize ?? 100 + this.request.totalNumberOfAttempts = preference.totalNumberOfAttempts ?? 1 + this.request.backlashCompensation.mode = preference.backlashCompensation.mode ?? 'NONE' + this.request.backlashCompensation.backlashIn = preference.backlashCompensation.backlashIn ?? 0 + this.request.backlashCompensation.backlashOut = preference.backlashCompensation.backlashOut ?? 0 + + if (this.camera.id) { + const cameraPreference = this.preference.cameraPreference(this.camera).get() + Object.assign(this.request.capture, this.preference.cameraStartCaptureForAutoFocus(this.camera).get(cameraPreference)) + + if (this.camera.connected) { + updateCameraStartCaptureFromCamera(this.request.capture, this.camera) + } + } + } + + savePreference() { + this.preference.cameraStartCaptureForAutoFocus(this.camera).set(this.request.capture) + + const preference: AutoFocusPreference = { + ...this.request + } + + this.preference.autoFocusPreference.set(preference) + } +} \ No newline at end of file diff --git a/desktop/src/app/calculator/calculator.component.ts b/desktop/src/app/calculator/calculator.component.ts index d67c42912..aa166ade6 100644 --- a/desktop/src/app/calculator/calculator.component.ts +++ b/desktop/src/app/calculator/calculator.component.ts @@ -22,6 +22,7 @@ export class CalculatorComponent { { label: 'Aperture', suffix: 'mm', + min: 1, }, { label: 'Focal Ratio', @@ -49,10 +50,12 @@ export class CalculatorComponent { { label: 'Focal Length', suffix: 'mm', + min: 1, }, { label: 'Aperture', suffix: 'mm', + min: 1, }, ], result: { @@ -76,6 +79,7 @@ export class CalculatorComponent { { label: 'Aperture', suffix: 'mm', + min: 1, }, ], result: { @@ -99,6 +103,7 @@ export class CalculatorComponent { { label: 'Aperture', suffix: 'mm', + min: 1, }, ], result: { @@ -122,6 +127,7 @@ export class CalculatorComponent { { label: 'Aperture', suffix: 'mm', + min: 1, }, ], result: { @@ -144,10 +150,12 @@ export class CalculatorComponent { { label: 'Larger Aperture', suffix: 'mm', + min: 1, }, { label: 'Smaller Aperture', suffix: 'mm', + min: 1, }, ], result: { @@ -171,10 +179,12 @@ export class CalculatorComponent { { label: 'Pixel Size', suffix: 'µm', + min: 1, }, { label: 'Focal Length', suffix: 'mm', + min: 1, }, ], result: { diff --git a/desktop/src/app/calculator/formula/formula.component.html b/desktop/src/app/calculator/formula/formula.component.html index 6d4083c3f..8b47fdc2b 100644 --- a/desktop/src/app/calculator/formula/formula.component.html +++ b/desktop/src/app/calculator/formula/formula.component.html @@ -12,7 +12,7 @@ + [min]="item.min ?? 0" [showButtons]="true" styleClass="border-0 p-inputtext-sm" locale="en" scrollableNumber />
@@ -26,9 +26,8 @@
{{ formula.result.prefix }} - + {{ formula.result.suffix }} diff --git a/desktop/src/app/calibration/calibration.component.html b/desktop/src/app/calibration/calibration.component.html index acfded5e3..a9f783024 100644 --- a/desktop/src/app/calibration/calibration.component.html +++ b/desktop/src/app/calibration/calibration.component.html @@ -52,7 +52,7 @@
- +
diff --git a/desktop/src/app/camera/camera.component.html b/desktop/src/app/camera/camera.component.html index 2127b8875..c082be6b0 100644 --- a/desktop/src/app/camera/camera.component.html +++ b/desktop/src/app/camera/camera.component.html @@ -2,7 +2,7 @@
- + void, device?: Device) => { - return { + return { icon: device ? 'mdi mdi-connection' : 'mdi mdi-close', label: device?.name ?? 'None', checked, @@ -332,7 +331,7 @@ export class CameraComponent implements AfterContentInit, OnDestroy { command() buildStartTooltip() this.preference.equipmentForDevice(this.camera).set(this.equipment) - event.parent?.menu?.forEach(item => item.checked = item === event.item) + event.parent?.subMenu?.forEach(item => item.checked = item === event.item) }, } } @@ -346,10 +345,10 @@ export class CameraComponent implements AfterContentInit, OnDestroy { return makeItem(this.equipment.mount?.name === mount?.name, () => this.equipment.mount = mount, mount) } - this.cameraModel[1].menu![0].menu!.push(makeMountItem()) + this.cameraModel[1].subMenu![0].subMenu!.push(makeMountItem()) for (const mount of mounts) { - this.cameraModel[1].menu![0].menu!.push(makeMountItem(mount)) + this.cameraModel[1].subMenu![0].subMenu!.push(makeMountItem(mount)) } // FILTER WHEEL @@ -361,10 +360,10 @@ export class CameraComponent implements AfterContentInit, OnDestroy { return makeItem(this.equipment.wheel?.name === wheel?.name, () => this.equipment.wheel = wheel, wheel) } - this.cameraModel[1].menu![1].menu!.push(makeWheelItem()) + this.cameraModel[1].subMenu![1].subMenu!.push(makeWheelItem()) for (const wheel of wheels) { - this.cameraModel[1].menu![1].menu!.push(makeWheelItem(wheel)) + this.cameraModel[1].subMenu![1].subMenu!.push(makeWheelItem(wheel)) } // FOCUSER @@ -376,10 +375,10 @@ export class CameraComponent implements AfterContentInit, OnDestroy { return makeItem(this.equipment.focuser?.name === focuser?.name, () => this.equipment.focuser = focuser, focuser) } - this.cameraModel[1].menu![2].menu!.push(makeFocuserItem()) + this.cameraModel[1].subMenu![2].subMenu!.push(makeFocuserItem()) for (const focuser of focusers) { - this.cameraModel[1].menu![2].menu!.push(makeFocuserItem(focuser)) + this.cameraModel[1].subMenu![2].subMenu!.push(makeFocuserItem(focuser)) } // ROTATOR @@ -391,10 +390,10 @@ export class CameraComponent implements AfterContentInit, OnDestroy { return makeItem(this.equipment.rotator?.name === rotator?.name, () => this.equipment.rotator = rotator, rotator) } - this.cameraModel[1].menu![3].menu!.push(makeRotatorItem()) + this.cameraModel[1].subMenu![3].subMenu!.push(makeRotatorItem()) for (const rotator of rotators) { - this.cameraModel[1].menu![3].menu!.push(makeRotatorItem(rotator)) + this.cameraModel[1].subMenu![3].subMenu!.push(makeRotatorItem(rotator)) } buildStartTooltip() @@ -409,7 +408,7 @@ export class CameraComponent implements AfterContentInit, OnDestroy { } const makeItem = (name?: string) => { - return { + return { label: name ?? 'None', icon: name ? 'mdi mdi-wrench' : 'mdi mdi-close', checked: this.request.calibrationGroup === name, diff --git a/desktop/src/app/filterwheel/filterwheel.component.html b/desktop/src/app/filterwheel/filterwheel.component.html index b6ff0a35f..9ae270e7d 100644 --- a/desktop/src/app/filterwheel/filterwheel.component.html +++ b/desktop/src/app/filterwheel/filterwheel.component.html @@ -2,7 +2,7 @@
- +
- +
- + - - - - +
@@ -21,7 +16,7 @@
- diff --git a/desktop/src/app/flat-wizard/flat-wizard.component.ts b/desktop/src/app/flat-wizard/flat-wizard.component.ts index 65ef1982e..c4edc631f 100644 --- a/desktop/src/app/flat-wizard/flat-wizard.component.ts +++ b/desktop/src/app/flat-wizard/flat-wizard.component.ts @@ -108,7 +108,6 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { } this.cameras.splice(index, 1) - this.cameras.sort(deviceComparator) } }) }) @@ -139,7 +138,6 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { } this.wheels.splice(index, 1) - this.wheels.sort(deviceComparator) } }) }) @@ -167,14 +165,6 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { } } - wheelConnect() { - if (this.wheel.connected) { - this.api.wheelDisconnect(this.wheel) - } else { - this.api.wheelConnect(this.wheel) - } - } - private updateEntryFromCamera(camera?: Camera) { if (camera && camera.connected) { updateCameraStartCaptureFromCamera(this.request.capture, camera) diff --git a/desktop/src/app/focuser/focuser.component.html b/desktop/src/app/focuser/focuser.component.html index 385b82a5d..4283b74eb 100644 --- a/desktop/src/app/focuser/focuser.component.html +++ b/desktop/src/app/focuser/focuser.component.html @@ -2,7 +2,7 @@
- +
- diff --git a/desktop/src/app/guider/guider.component.html b/desktop/src/app/guider/guider.component.html index 067d5df5c..e5316013e 100644 --- a/desktop/src/app/guider/guider.component.html +++ b/desktop/src/app/guider/guider.component.html @@ -2,15 +2,15 @@
-
+
-
-
+
@@ -31,44 +31,44 @@
- -
+ +
-
-
-
- +
- +
- +
@@ -89,16 +89,15 @@
- +
North East
- -
-
+ +
+
Settle tolerance (px)
-
+
- +
-
+
- - - +
diff --git a/desktop/src/app/guider/guider.component.ts b/desktop/src/app/guider/guider.component.ts index a3c79fd0c..f5d8b7386 100644 --- a/desktop/src/app/guider/guider.component.ts +++ b/desktop/src/app/guider/guider.component.ts @@ -69,7 +69,7 @@ export class GuiderComponent implements AfterViewInit, OnDestroy { } readonly chartData: ChartData = { - labels: Array.from({ length: 100 }, (_, i) => `${i}`), + labels: Array.from({ length: 100 }), datasets: [ // RA. { @@ -123,7 +123,6 @@ export class GuiderComponent implements AfterViewInit, OnDestroy { return '' }, label: (context) => { - console.log(context) const barType = context.dataset.type === 'bar' const raType = context.datasetIndex === 0 || context.datasetIndex === 2 const scale = barType ? this.phdDurationScale : 1.0 @@ -165,12 +164,12 @@ export class GuiderComponent implements AfterViewInit, OnDestroy { }, scales: { y: { - stacked: true, + stacked: false, beginAtZero: false, min: -16, max: 16, ticks: { - autoSkip: false, + autoSkip: true, count: 7, callback: (value) => { return (value as number).toFixed(1).padStart(5, ' ') @@ -188,7 +187,7 @@ export class GuiderComponent implements AfterViewInit, OnDestroy { } }, x: { - stacked: true, + stacked: false, min: 0, max: 100, border: { @@ -196,11 +195,13 @@ export class GuiderComponent implements AfterViewInit, OnDestroy { dash: [2, 4], }, ticks: { - stepSize: 5.0, + autoSkip: true, + count: 11, maxRotation: 0, minRotation: 0, callback: (value) => { - return (value as number).toFixed(0) + const a = value as number + return (a - Math.trunc(a) > 0) ? undefined : a.toFixed(0) } }, grid: { @@ -374,14 +375,6 @@ export class GuiderComponent implements AfterViewInit, OnDestroy { } } - connectGuideOutput() { - if (this.guideOutputConnected) { - this.api.guideOutputDisconnect(this.guideOutput!) - } else { - this.api.guideOutputConnect(this.guideOutput!) - } - } - guidePulseStart(...directions: GuideDirection[]) { for (const direction of directions) { switch (direction) { diff --git a/desktop/src/app/home/home.component.html b/desktop/src/app/home/home.component.html index 728ba83e0..2fa0bf5cc 100644 --- a/desktop/src/app/home/home.component.html +++ b/desktop/src/app/home/home.component.html @@ -127,6 +127,13 @@
Framing
+
+ + +
Auto Focus
+
+
@@ -197,5 +204,6 @@ - + \ No newline at end of file diff --git a/desktop/src/app/home/home.component.ts b/desktop/src/app/home/home.component.ts index 207e94e4b..d9dd33d22 100644 --- a/desktop/src/app/home/home.component.ts +++ b/desktop/src/app/home/home.component.ts @@ -1,7 +1,7 @@ import { AfterContentInit, Component, HostListener, NgZone, OnDestroy, ViewChild } from '@angular/core' import { dirname } from 'path' -import { DeviceListMenuComponent } from '../../shared/components/device-list-menu/device-list-menu.component' -import { DialogMenuComponent } from '../../shared/components/dialog-menu/dialog-menu.component' +import { DeviceChooserComponent } from '../../shared/components/device-chooser/device-chooser.component' +import { DeviceConnectionCommandEvent, DeviceListMenuComponent } from '../../shared/components/device-list-menu/device-list-menu.component' import { SlideMenuItem } from '../../shared/components/slide-menu/slide-menu.component' import { ApiService } from '../../shared/services/api.service' import { BrowserWindowService } from '../../shared/services/browser-window.service' @@ -15,7 +15,6 @@ import { CONNECTION_TYPES, ConnectionDetails, EMPTY_CONNECTION_DETAILS, HomeWind import { Mount } from '../../shared/types/mount.types' import { Rotator } from '../../shared/types/rotator.types' import { FilterWheel } from '../../shared/types/wheel.types' -import { deviceComparator } from '../../shared/utils/comparators' import { AppComponent } from '../app.component' type MappedDevice = { @@ -34,7 +33,7 @@ type MappedDevice = { export class HomeComponent implements AfterContentInit, OnDestroy { @ViewChild('deviceMenu') - private readonly deviceMenu!: DialogMenuComponent + private readonly deviceMenu!: DeviceListMenuComponent @ViewChild('imageMenu') private readonly imageMenu!: DeviceListMenuComponent @@ -100,6 +99,10 @@ export class HomeComponent implements AfterContentInit, OnDestroy { return this.hasCamera } + get hasAutoFocus() { + return this.hasCamera && this.hasFocuser + } + get hasFlatWizard() { return this.hasCamera } @@ -133,6 +136,7 @@ export class HomeComponent implements AfterContentInit, OnDestroy { type: K, onAdd: (device: MappedDevice[K]) => number, onRemove: (device: MappedDevice[K]) => number, + onUpdate: (device: MappedDevice[K]) => void, ) { this.electron.on(`${type}.ATTACHED`, event => { this.ngZone.run(() => { @@ -145,6 +149,12 @@ export class HomeComponent implements AfterContentInit, OnDestroy { onRemove(event.device as never) }) }) + + this.electron.on(`${type}.UPDATED`, event => { + this.ngZone.run(() => { + onUpdate(event.device as never) + }) + }) } constructor( @@ -159,53 +169,78 @@ export class HomeComponent implements AfterContentInit, OnDestroy { app.title = 'Nebulosa' this.startListening('CAMERA', - (device) => { + device => { return this.cameras.push(device) }, - (device) => { + device => { this.cameras.splice(this.cameras.findIndex(e => e.id === device.id), 1) return this.cameras.length }, + device => { + const found = this.cameras.find(e => e.id === device.id) + if (!found) return + Object.assign(found, device) + } ) this.startListening('MOUNT', - (device) => { + device => { return this.mounts.push(device) }, - (device) => { + device => { this.mounts.splice(this.mounts.findIndex(e => e.id === device.id), 1) return this.mounts.length }, + device => { + const found = this.mounts.find(e => e.id === device.id) + if (!found) return + Object.assign(found, device) + } ) this.startListening('FOCUSER', - (device) => { + device => { return this.focusers.push(device) }, - (device) => { + device => { this.focusers.splice(this.focusers.findIndex(e => e.id === device.id), 1) return this.focusers.length }, + device => { + const found = this.focusers.find(e => e.id === device.id) + if (!found) return + Object.assign(found, device) + } ) this.startListening('WHEEL', - (device) => { + device => { return this.wheels.push(device) }, - (device) => { + device => { this.wheels.splice(this.wheels.findIndex(e => e.id === device.id), 1) return this.wheels.length }, + device => { + const found = this.wheels.find(e => e.id === device.id) + if (!found) return + Object.assign(found, device) + } ) this.startListening('ROTATOR', - (device) => { + device => { return this.rotators.push(device) }, - (device) => { + device => { this.rotators.splice(this.rotators.findIndex(e => e.id === device.id), 1) return this.rotators.length }, + device => { + const found = this.rotators.find(e => e.id === device.id) + if (!found) return + Object.assign(found, device) + } ) electron.on('CONNECTION.CLOSED', event => { @@ -319,7 +354,23 @@ export class HomeComponent implements AfterContentInit, OnDestroy { } } - private openDevice(type: K, header: string) { + protected findDeviceById(id: string) { + return this.cameras.find(e => e.id === id) || + this.mounts.find(e => e.id === id) || + this.wheels.find(e => e.id === id) || + this.focusers.find(e => e.id === id) || + this.rotators.find(e => e.id === id) + } + + protected async deviceConnected(event: DeviceConnectionCommandEvent) { + DeviceChooserComponent.handleConnectDevice(this.api, event.device, event.item) + } + + protected async deviceDisconnected(event: DeviceConnectionCommandEvent) { + DeviceChooserComponent.handleDisconnectDevice(this.api, event.device, event.item) + } + + private async openDevice(type: K) { this.deviceModel.length = 0 const devices: Device[] = type === 'CAMERA' ? this.cameras @@ -330,20 +381,13 @@ export class HomeComponent implements AfterContentInit, OnDestroy { : [] if (devices.length === 0) return - if (devices.length === 1) return this.openDeviceWindow(type, devices[0] as any) - - for (const device of [...devices].sort(deviceComparator)) { - this.deviceModel.push({ - icon: 'mdi mdi-connection', - label: device.name, - command: () => { - this.openDeviceWindow(type, device as any) - } - }) - } - this.deviceMenu.header = header - this.deviceMenu.show() + this.deviceMenu.header = type + const device = await this.deviceMenu.show(devices) + + if (device && device !== 'NONE') { + this.openDeviceWindow(type, device as any) + } } private openDeviceWindow(type: K, device: MappedDevice[K]) { @@ -392,7 +436,7 @@ export class HomeComponent implements AfterContentInit, OnDestroy { case 'FOCUSER': case 'WHEEL': case 'ROTATOR': - this.openDevice(type, type) + this.openDevice(type) break case 'GUIDER': this.browserWindow.openGuider({ bringToFront: true }) @@ -409,6 +453,9 @@ export class HomeComponent implements AfterContentInit, OnDestroy { case 'SEQUENCER': this.browserWindow.openSequencer({ bringToFront: true }) break + case 'AUTO_FOCUS': + this.browserWindow.openAutoFocus({ bringToFront: true }) + break case 'FLAT_WIZARD': this.browserWindow.openFlatWizard({ bringToFront: true }) break diff --git a/desktop/src/app/image/image.component.html b/desktop/src/app/image/image.component.html index b9872f8ce..dbb15221c 100644 --- a/desktop/src/app/image/image.component.html +++ b/desktop/src/app/image/image.component.html @@ -94,46 +94,46 @@
- +
-
-
-
- +
- +
- @@ -191,38 +191,38 @@
- +
- +
- +
- +
-
- +
@@ -348,55 +348,55 @@
- +
-
-
-
-
-
-
- @@ -581,7 +581,7 @@
- +
diff --git a/desktop/src/app/image/image.component.ts b/desktop/src/app/image/image.component.ts index 5866d9700..1f06971c5 100644 --- a/desktop/src/app/image/image.component.ts +++ b/desktop/src/app/image/image.component.ts @@ -8,7 +8,7 @@ import { basename, dirname, extname } from 'path' import { ContextMenu } from 'primeng/contextmenu' import { DeviceListMenuComponent } from '../../shared/components/device-list-menu/device-list-menu.component' import { HistogramComponent } from '../../shared/components/histogram/histogram.component' -import { ExtendedMenuItem } from '../../shared/components/menu-item/menu-item.component' +import { MenuItem } from '../../shared/components/menu-item/menu-item.component' import { SlideMenuItem } from '../../shared/components/slide-menu/slide-menu.component' import { SEPARATOR_MENU_ITEM } from '../../shared/constants' import { ApiService } from '../../shared/services/api.service' @@ -16,12 +16,10 @@ import { BrowserWindowService } from '../../shared/services/browser-window.servi import { ElectronService } from '../../shared/services/electron.service' import { PreferenceService } from '../../shared/services/preference.service' import { PrimeService } from '../../shared/services/prime.service' -import { CheckableMenuItem, ToggleableMenuItem } from '../../shared/types/app.types' import { Angle, AstronomicalObject, DeepSkyObject, EquatorialCoordinateJ2000, Star } from '../../shared/types/atlas.types' import { Camera } from '../../shared/types/camera.types' import { DEFAULT_FOV, EMPTY_IMAGE_SOLVED, FOV, IMAGE_STATISTICS_BIT_OPTIONS, ImageAnnotation, ImageAnnotationDialog, ImageChannel, ImageData, ImageDetectStars, ImageFITSHeadersDialog, ImageFOVDialog, ImageInfo, ImageROI, ImageSCNRDialog, ImageSaveDialog, ImageSolved, ImageSolverDialog, ImageStatisticsBitOption, ImageStretchDialog, ImageTransformation, SCNR_PROTECTION_METHODS } from '../../shared/types/image.types' import { Mount } from '../../shared/types/mount.types' -import { DEFAULT_SOLVER_TYPES } from '../../shared/types/settings.types' import { CoordinateInterpolator, InterpolatedCoordinate } from '../../shared/utils/coordinate-interpolation' import { AppComponent } from '../app.component' @@ -105,7 +103,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { centerDEC: '', radius: 4, solved: structuredClone(EMPTY_IMAGE_SOLVED), - types: Array.from(DEFAULT_SOLVER_TYPES), + types: ['ASTAP', 'ASTROMETRY_NET_ONLINE'], type: 'ASTAP' } @@ -169,7 +167,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { transformation: this.transformation } - private readonly saveAsMenuItem: ExtendedMenuItem = { + private readonly saveAsMenuItem: MenuItem = { label: 'Save as...', icon: 'mdi mdi-content-save', command: async () => { @@ -191,7 +189,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } - private readonly plateSolveMenuItem: ExtendedMenuItem = { + private readonly plateSolveMenuItem: MenuItem = { label: 'Plate Solve', icon: 'mdi mdi-sigma', command: () => { @@ -199,7 +197,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } - private readonly stretchMenuItem: ExtendedMenuItem = { + private readonly stretchMenuItem: MenuItem = { label: 'Stretch', icon: 'mdi mdi-chart-histogram', command: () => { @@ -207,7 +205,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } - private readonly autoStretchMenuItem: CheckableMenuItem = { + private readonly autoStretchMenuItem: MenuItem = { id: 'auto-stretch-menuitem', label: 'Auto stretch', icon: 'mdi mdi-auto-fix', @@ -217,7 +215,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } - private readonly scnrMenuItem: ExtendedMenuItem = { + private readonly scnrMenuItem: MenuItem = { label: 'SCNR', icon: 'mdi mdi-palette', disabled: true, @@ -226,7 +224,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } - private readonly horizontalMirrorMenuItem: CheckableMenuItem = { + private readonly horizontalMirrorMenuItem: MenuItem = { label: 'Horizontal mirror', icon: 'mdi mdi-flip-horizontal', checked: false, @@ -237,7 +235,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } - private readonly verticalMirrorMenuItem: CheckableMenuItem = { + private readonly verticalMirrorMenuItem: MenuItem = { label: 'Vertical mirror', icon: 'mdi mdi-flip-vertical', checked: false, @@ -248,7 +246,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } - private readonly invertMenuItem: CheckableMenuItem = { + private readonly invertMenuItem: MenuItem = { label: 'Invert', icon: 'mdi mdi-invert-colors', checked: false, @@ -257,13 +255,13 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } - private readonly calibrationMenuItem: ExtendedMenuItem = { + private readonly calibrationMenuItem: MenuItem = { label: 'Calibration', icon: 'mdi mdi-wrench', items: [], } - private readonly statisticsMenuItem: ExtendedMenuItem = { + private readonly statisticsMenuItem: MenuItem = { icon: 'mdi mdi-chart-histogram', label: 'Statistics', command: () => { @@ -272,7 +270,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } - private readonly fitsHeaderMenuItem: ExtendedMenuItem = { + private readonly fitsHeaderMenuItem: MenuItem = { icon: 'mdi mdi-list-box', label: 'FITS Header', command: () => { @@ -280,7 +278,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } - private readonly pointMountHereMenuItem: ExtendedMenuItem = { + private readonly pointMountHereMenuItem: MenuItem = { label: 'Point mount here', icon: 'mdi mdi-telescope', disabled: true, @@ -291,7 +289,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } - private readonly frameAtThisCoordinateMenuItem: ExtendedMenuItem = { + private readonly frameAtThisCoordinateMenuItem: MenuItem = { label: 'Frame at this coordinate', icon: 'mdi mdi-image', disabled: true, @@ -304,7 +302,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } - private readonly crosshairMenuItem: CheckableMenuItem = { + private readonly crosshairMenuItem: MenuItem = { label: 'Crosshair', icon: 'mdi mdi-bullseye', checked: false, @@ -313,7 +311,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } - private readonly annotationMenuItem: ToggleableMenuItem = { + private readonly annotationMenuItem: MenuItem = { label: 'Annotate', icon: 'mdi mdi-marker', disabled: true, @@ -328,14 +326,15 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } - private readonly detectStarsMenuItem: ToggleableMenuItem = { + private readonly detectStarsMenuItem: MenuItem = { label: 'Detect stars', icon: 'mdi mdi-creation', disabled: false, toggleable: false, toggled: false, command: async () => { - this.detectedStars.stars = await this.api.detectStars(this.imageData.path!) + const options = this.preference.starDetectionOptions('ASTAP').get() + this.detectedStars.stars = await this.api.detectStars(this.imageData.path!, options) this.detectedStars.visible = this.detectedStars.stars.length > 0 this.detectStarsMenuItem.toggleable = this.detectedStars.visible this.detectStarsMenuItem.toggled = this.detectedStars.visible @@ -346,7 +345,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } - private readonly roiMenuItem: CheckableMenuItem = { + private readonly roiMenuItem: MenuItem = { label: 'ROI', icon: 'mdi mdi-select', checked: false, @@ -386,7 +385,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } - private readonly fovMenuItem: ExtendedMenuItem = { + private readonly fovMenuItem: MenuItem = { label: 'Field of View', icon: 'mdi mdi-camera-metering-spot', command: () => { @@ -398,7 +397,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } - private readonly overlayMenuItem: ExtendedMenuItem = { + private readonly overlayMenuItem: MenuItem = { label: 'Overlay', icon: 'mdi mdi-layers', items: [ @@ -573,7 +572,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { const label = name ?? 'None' const icon = name ? 'mdi mdi-wrench' : 'mdi mdi-close' - return { + return { label, icon, checked: this.transformation.calibrationGroup === name, disabled: this.calibrationViaCamera, @@ -949,7 +948,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { this.solver.solving = true try { - const solver = this.preference.plateSolverPreference(this.solver.type).get() + const solver = this.preference.plateSolverOptions(this.solver.type).get() const solved = await this.api.solveImage(solver, this.imageData.path!, this.solver.blind, this.solver.centerRA, this.solver.centerDEC, this.solver.radius) @@ -959,7 +958,10 @@ export class ImageComponent implements AfterViewInit, OnDestroy { this.updateImageSolved(this.imageInfo?.solved) } finally { this.solver.solving = false - this.retrieveCoordinateInterpolation() + + if (this.solver.solved.solved) { + this.retrieveCoordinateInterpolation() + } } } diff --git a/desktop/src/app/indi/property/indi-property.component.html b/desktop/src/app/indi/property/indi-property.component.html index 458bb5a0e..7b5c9c3f6 100644 --- a/desktop/src/app/indi/property/indi-property.component.html +++ b/desktop/src/app/indi/property/indi-property.component.html @@ -25,14 +25,13 @@
- +
- +
@@ -48,15 +47,13 @@
- +
- +
diff --git a/desktop/src/app/mount/mount.component.html b/desktop/src/app/mount/mount.component.html index 769ee72a2..471ac72fc 100644 --- a/desktop/src/app/mount/mount.component.html +++ b/desktop/src/app/mount/mount.component.html @@ -2,7 +2,7 @@
- +
- +
- +
- +
- +
- +
- +
- +
- +
- +
- +
diff --git a/desktop/src/app/mount/mount.component.ts b/desktop/src/app/mount/mount.component.ts index fa90749d8..c853d97cf 100644 --- a/desktop/src/app/mount/mount.component.ts +++ b/desktop/src/app/mount/mount.component.ts @@ -124,7 +124,7 @@ export class MountComponent implements AfterContentInit, OnDestroy { { icon: 'mdi mdi-crosshairs-gps', label: 'Locations', - menu: [ + subMenu: [ { icon: 'mdi mdi-crosshairs-gps', label: 'Current location', @@ -178,7 +178,7 @@ export class MountComponent implements AfterContentInit, OnDestroy { { icon: 'mdi mdi-crosshairs', label: 'Intersection points', - menu: [ + subMenu: [ { icon: 'mdi mdi-crosshairs-gps', label: 'Meridian x Equator', diff --git a/desktop/src/app/rotator/rotator.component.html b/desktop/src/app/rotator/rotator.component.html index 17e1b1e6f..b7cb53b0b 100644 --- a/desktop/src/app/rotator/rotator.component.html +++ b/desktop/src/app/rotator/rotator.component.html @@ -2,7 +2,7 @@
- +
- diff --git a/desktop/src/app/sequencer/sequencer.component.html b/desktop/src/app/sequencer/sequencer.component.html index dfb9eabb2..556df7628 100644 --- a/desktop/src/app/sequencer/sequencer.component.html +++ b/desktop/src/app/sequencer/sequencer.component.html @@ -29,7 +29,7 @@ [positionTop]="8"> - @@ -136,11 +136,11 @@
- - - - + + + +
diff --git a/desktop/src/app/sequencer/sequencer.component.ts b/desktop/src/app/sequencer/sequencer.component.ts index 9833a323f..abd8516e1 100644 --- a/desktop/src/app/sequencer/sequencer.component.ts +++ b/desktop/src/app/sequencer/sequencer.component.ts @@ -470,7 +470,7 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { this.savePlan() - await this.browserWindow.openCameraImage(this.camera!) + await this.browserWindow.openCameraImage(this.camera!, 'SEQUENCER') this.api.sequencerStart(this.camera!, this.plan) } diff --git a/desktop/src/app/settings/settings.component.html b/desktop/src/app/settings/settings.component.html index e2084571b..0931108e9 100644 --- a/desktop/src/app/settings/settings.component.html +++ b/desktop/src/app/settings/settings.component.html @@ -19,7 +19,7 @@
- @@ -42,7 +42,7 @@ (ngModelChange)="solvers.get(solverType)!.executablePath = $event; save()" /> - +
+ +
+ + + + + + + + + + + + + + + +
+
\ No newline at end of file diff --git a/desktop/src/app/settings/settings.component.ts b/desktop/src/app/settings/settings.component.ts index 4567d72ce..2d8931004 100644 --- a/desktop/src/app/settings/settings.component.ts +++ b/desktop/src/app/settings/settings.component.ts @@ -6,7 +6,7 @@ import { ElectronService } from '../../shared/services/electron.service' import { PreferenceService } from '../../shared/services/preference.service' import { PrimeService } from '../../shared/services/prime.service' import { EMPTY_LOCATION, Location } from '../../shared/types/atlas.types' -import { DEFAULT_SOLVER_TYPES, PlateSolverPreference, PlateSolverType } from '../../shared/types/settings.types' +import { PlateSolverOptions, PlateSolverType, StarDetectionOptions, StarDetectorType } from '../../shared/types/settings.types' import { AppComponent } from '../app.component' @Component({ @@ -19,9 +19,11 @@ export class SettingsComponent implements AfterViewInit, OnDestroy { readonly locations: Location[] location: Location - readonly solverTypes = Array.from(DEFAULT_SOLVER_TYPES) - solverType = this.solverTypes[0] - readonly solvers = new Map() + solverType: PlateSolverType = 'ASTAP' + readonly solvers = new Map() + + starDetectorType: StarDetectorType = 'ASTAP' + readonly starDetectors = new Map() constructor( app: AppComponent, @@ -35,9 +37,10 @@ export class SettingsComponent implements AfterViewInit, OnDestroy { this.locations = preference.locations.get() this.location = preference.selectedLocation.get(this.locations[0]) - for (const type of this.solverTypes) { - this.solvers.set(type, preference.plateSolverPreference(type).get()) - } + this.solvers.set('ASTAP', preference.plateSolverOptions('ASTAP').get()) + this.solvers.set('ASTROMETRY_NET_ONLINE', preference.plateSolverOptions('ASTROMETRY_NET_ONLINE').get()) + + this.starDetectors.set('ASTAP', preference.starDetectionOptions('ASTAP').get()) } async ngAfterViewInit() { } @@ -99,19 +102,31 @@ export class SettingsComponent implements AfterViewInit, OnDestroy { this.electron.send('LOCATION.CHANGED', this.location) } - async chooseExecutablePath() { + async chooseExecutablePathForPlateSolver() { const options = this.solvers.get(this.solverType)! + this.chooseExecutablePath(options) + } + + async chooseExecutablePathForStarDetection() { + const options = this.solvers.get(this.starDetectorType)! + this.chooseExecutablePath(options) + } + + private async chooseExecutablePath(options: { executablePath: string }) { const executablePath = await this.electron.openFile({ defaultPath: path.dirname(options.executablePath) }) if (executablePath) { options.executablePath = executablePath this.save() } + + return executablePath } save() { - for (const type of this.solverTypes) { - this.preference.plateSolverPreference(type).set(this.solvers.get(type)!) - } + this.preference.plateSolverOptions('ASTAP').set(this.solvers.get('ASTAP')!) + this.preference.plateSolverOptions('ASTROMETRY_NET_ONLINE').set(this.solvers.get('ASTROMETRY_NET_ONLINE')!) + + this.preference.starDetectionOptions('ASTAP').set(this.starDetectors.get('ASTAP')!) } } \ No newline at end of file diff --git a/desktop/src/assets/icons/auto-focus.png b/desktop/src/assets/icons/auto-focus.png new file mode 100644 index 000000000..eb3b6d8a7 Binary files /dev/null and b/desktop/src/assets/icons/auto-focus.png differ diff --git a/desktop/src/shared/components/device-list-button/device-list-button.component.html b/desktop/src/shared/components/device-chooser/device-chooser.component.html similarity index 79% rename from desktop/src/shared/components/device-list-button/device-list-button.component.html rename to desktop/src/shared/components/device-chooser/device-chooser.component.html index 94bd838b9..10733a640 100644 --- a/desktop/src/shared/components/device-list-button/device-list-button.component.html +++ b/desktop/src/shared/components/device-chooser/device-chooser.component.html @@ -12,4 +12,5 @@
- \ No newline at end of file + \ No newline at end of file diff --git a/desktop/src/shared/components/device-chooser/device-chooser.component.scss b/desktop/src/shared/components/device-chooser/device-chooser.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/desktop/src/shared/components/device-chooser/device-chooser.component.ts b/desktop/src/shared/components/device-chooser/device-chooser.component.ts new file mode 100644 index 000000000..33c6a6087 --- /dev/null +++ b/desktop/src/shared/components/device-chooser/device-chooser.component.ts @@ -0,0 +1,114 @@ +import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core' +import { ApiService } from '../../services/api.service' +import { Device } from '../../types/device.types' +import { DeviceConnectionCommandEvent, DeviceListMenuComponent } from '../device-list-menu/device-list-menu.component' +import { MenuItem } from '../menu-item/menu-item.component' + +@Component({ + selector: 'neb-device-chooser', + templateUrl: './device-chooser.component.html', + styleUrls: ['./device-chooser.component.scss'], +}) +export class DeviceChooserComponent { + + @Input({ required: true }) + readonly title!: string + + @Input() + readonly noDeviceMessage?: string + + @Input({ required: true }) + readonly icon!: string + + @Input({ required: true }) + readonly devices!: T[] + + @Input() + readonly hasNone: boolean = false + + @Input() + device?: T + + @Output() + readonly deviceChange = new EventEmitter() + + @Output() + readonly deviceConnect = new EventEmitter() + + @Output() + readonly deviceDisconnect = new EventEmitter() + + @ViewChild('deviceMenu') + private readonly deviceMenu!: DeviceListMenuComponent + + constructor(private api: ApiService) { } + + async show() { + const device = await this.deviceMenu.show(this.devices, this.device) + + if (device) { + this.device = device === 'NONE' ? undefined : device + this.deviceChange.emit(this.device) + } + } + + hide() { + this.deviceMenu.hide() + } + + protected async deviceConnected(event: DeviceConnectionCommandEvent) { + const newEvent = await DeviceChooserComponent.handleConnectDevice(this.api, event.device, event.item) + if (newEvent) this.deviceConnect.emit(newEvent) + } + + protected async deviceDisconnected(event: DeviceConnectionCommandEvent) { + const newEvent = await DeviceChooserComponent.handleDisconnectDevice(this.api, event.device, event.item) + if (newEvent) this.deviceDisconnect.emit(newEvent) + } + + static async handleConnectDevice(api: ApiService, device: Device, item: MenuItem) { + await api.indiDeviceConnect(device) + + item.disabled = true + + return new Promise((resolve) => { + setTimeout(async () => { + Object.assign(device, await api.indiDevice(device)) + + if (device.connected) { + item.icon = 'mdi mdi-close' + item.toolbarButtonSeverity = 'danger' + item.label = 'Disconnect' + resolve({ device, item }) + } else { + resolve(undefined) + } + + item.disabled = false + }, 1000) + }) + } + + static async handleDisconnectDevice(api: ApiService, device: Device, item: MenuItem) { + await api.indiDeviceDisconnect(device) + + item.disabled = true + + return new Promise((resolve) => { + setTimeout(async () => { + Object.assign(device, await api.indiDevice(device)) + + if (!device.connected) { + item.icon = 'mdi mdi-connection' + item.toolbarButtonSeverity = 'info' + item.label = 'Connect' + resolve({ device, item }) + } else { + resolve(undefined) + } + + item.disabled = false + }, 1000) + }) + } +} \ No newline at end of file diff --git a/desktop/src/shared/components/device-list-button/device-list-button.component.ts b/desktop/src/shared/components/device-list-button/device-list-button.component.ts deleted file mode 100644 index 17c101c42..000000000 --- a/desktop/src/shared/components/device-list-button/device-list-button.component.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core' -import { Device } from '../../types/device.types' -import { DeviceListMenuComponent } from '../device-list-menu/device-list-menu.component' - -@Component({ - selector: 'neb-device-list-button', - templateUrl: './device-list-button.component.html', - styleUrls: ['./device-list-button.component.scss'], -}) -export class DeviceListButtonComponent { - - @Input({ required: true }) - readonly title!: string - - @Input() - readonly noDeviceMessage?: string - - @Input({ required: true }) - readonly icon!: string - - @Input({ required: true }) - readonly devices!: Device[] - - @Input() - readonly hasNone: boolean = false - - @Input() - device?: Device - - @Output() - readonly deviceChange = new EventEmitter() - - @ViewChild('deviceMenu') - private readonly deviceMenu!: DeviceListMenuComponent - - async show() { - const device = await this.deviceMenu.show(this.devices, this.device) - - if (device) { - this.device = device === 'NONE' ? undefined : device - this.deviceChange.emit(this.device) - } - } - - hide() { - this.deviceMenu.hide() - } -} \ No newline at end of file diff --git a/desktop/src/shared/components/device-list-menu/device-list-menu.component.scss b/desktop/src/shared/components/device-list-menu/device-list-menu.component.scss index e69de29bb..abfb55957 100644 --- a/desktop/src/shared/components/device-list-menu/device-list-menu.component.scss +++ b/desktop/src/shared/components/device-list-menu/device-list-menu.component.scss @@ -0,0 +1,6 @@ +:host { + ::ng-deep .p-menuitem-link { + padding: 0.5rem 0.75rem; + min-height: 43px; + } +} \ No newline at end of file diff --git a/desktop/src/shared/components/device-list-menu/device-list-menu.component.ts b/desktop/src/shared/components/device-list-menu/device-list-menu.component.ts index de139677f..d09d1e0b5 100644 --- a/desktop/src/shared/components/device-list-menu/device-list-menu.component.ts +++ b/desktop/src/shared/components/device-list-menu/device-list-menu.component.ts @@ -1,11 +1,18 @@ -import { Component, Input, ViewChild } from '@angular/core' +import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core' import { SEPARATOR_MENU_ITEM } from '../../constants' import { PrimeService } from '../../services/prime.service' +import { isGuideHead } from '../../types/camera.types' import { Device } from '../../types/device.types' import { deviceComparator } from '../../utils/comparators' import { DialogMenuComponent } from '../dialog-menu/dialog-menu.component' +import { MenuItem } from '../menu-item/menu-item.component' import { SlideMenuItem } from '../slide-menu/slide-menu.component' +export interface DeviceConnectionCommandEvent { + device: Device + item: MenuItem +} + @Component({ selector: 'neb-device-list-menu', templateUrl: './device-list-menu.component.html', @@ -28,6 +35,12 @@ export class DeviceListMenuComponent { @Input() readonly hasNone: boolean = false + @Output() + readonly deviceConnect = new EventEmitter() + + @Output() + readonly deviceDisconnect = new EventEmitter() + @ViewChild('menu') private readonly menu!: DialogMenuComponent @@ -67,10 +80,21 @@ export class DeviceListMenuComponent { for (const device of devices.sort(deviceComparator)) { model.push({ - icon: 'mdi mdi-circle-medium ' + (device.connected ? 'text-green-500' : 'text-red-500'), label: device.name, checked: selected === device, disabled: this.disableIfDeviceIsNotConnected && !device.connected, + toolbarMenu: [ + { + icon: 'mdi ' + (device.connected ? 'mdi-close' : 'mdi-connection'), + toolbarButtonSeverity: device.connected ? 'danger' : 'info', + label: device.connected ? 'Disconnect' : 'Connect', + visible: !isGuideHead(device), + command: event => { + if (device.connected) this.deviceDisconnect.emit({ device, item: event.item! }) + else this.deviceConnect.emit({ device, item: event.item! }) + } + } + ], command: () => { resolve(device) }, diff --git a/desktop/src/shared/components/dialog-menu/dialog-menu.component.ts b/desktop/src/shared/components/dialog-menu/dialog-menu.component.ts index 7c811cfb5..efdc0dcc2 100644 --- a/desktop/src/shared/components/dialog-menu/dialog-menu.component.ts +++ b/desktop/src/shared/components/dialog-menu/dialog-menu.component.ts @@ -1,5 +1,5 @@ import { Component, EventEmitter, Input, Output } from '@angular/core' -import { ExtendedMenuItem } from '../menu-item/menu-item.component' +import { MenuItem } from '../menu-item/menu-item.component' import { SlideMenuItemCommandEvent } from '../slide-menu/slide-menu.component' @Component({ @@ -16,7 +16,7 @@ export class DialogMenuComponent { readonly visibleChange = new EventEmitter() @Input() - model: ExtendedMenuItem[] = [] + model: MenuItem[] = [] @Input() header?: string @@ -38,7 +38,7 @@ export class DialogMenuComponent { } next(event: SlideMenuItemCommandEvent) { - if (!event.item?.menu?.length) { + if (!event.item?.subMenu?.length) { this.hide() } else { this.navigationHeader.push(this.header) diff --git a/desktop/src/shared/components/menu-item/menu-item.component.html b/desktop/src/shared/components/menu-item/menu-item.component.html index 03f61f537..4fd51b180 100644 --- a/desktop/src/shared/components/menu-item/menu-item.component.html +++ b/desktop/src/shared/components/menu-item/menu-item.component.html @@ -5,18 +5,17 @@ @if (item.toolbarMenu?.length) {
@for (m of item.toolbarMenu; track i; let i = $index) { - }
} - @if (item.checked) { - - } @else if(item.toggleable) { + @if(item.toggleable) { } - @if (item.items?.length || item.menu?.length) { + @if (item.items?.length || item.subMenu?.length) { } \ No newline at end of file diff --git a/desktop/src/shared/components/menu-item/menu-item.component.ts b/desktop/src/shared/components/menu-item/menu-item.component.ts index 71fbac809..616b6ee1f 100644 --- a/desktop/src/shared/components/menu-item/menu-item.component.ts +++ b/desktop/src/shared/components/menu-item/menu-item.component.ts @@ -1,15 +1,27 @@ import { Component, Input } from '@angular/core' -import { MenuItem, MenuItemCommandEvent } from 'primeng/api' -import { CheckableMenuItem, ToggleableMenuItem } from '../../types/app.types' +import { MenuItem as PrimeMenuItem, MenuItemCommandEvent as PrimeMenuItemCommandEvent } from 'primeng/api' +import { CheckboxChangeEvent } from 'primeng/checkbox' +import { Severity } from '../../types/app.types' -export interface ExtendedMenuItemCommandEvent extends MenuItemCommandEvent { - item?: ExtendedMenuItem +export interface MenuItemCommandEvent extends PrimeMenuItemCommandEvent { + item?: MenuItem } -export interface ExtendedMenuItem extends MenuItem, Partial, Partial { - menu?: ExtendedMenuItem[] - toolbarMenu?: ExtendedMenuItem[] - command?: (event: ExtendedMenuItemCommandEvent) => void +export interface MenuItem extends PrimeMenuItem { + badgeSeverity?: Severity + + checked?: boolean + + toggleable?: boolean + toggled?: boolean + + subMenu?: MenuItem[] + + toolbarMenu?: MenuItem[] + toolbarButtonSeverity?: Severity + + command?: (event: MenuItemCommandEvent) => void + toggle?: (event: CheckboxChangeEvent) => void } @Component({ @@ -20,5 +32,5 @@ export interface ExtendedMenuItem extends MenuItem, Partial, export class MenuItemComponent { @Input({ required: true }) - readonly item!: ExtendedMenuItem + readonly item!: MenuItem } \ No newline at end of file diff --git a/desktop/src/shared/components/slide-menu/slide-menu.component.html b/desktop/src/shared/components/slide-menu/slide-menu.component.html index 3cc80d1a2..27dd182cd 100644 --- a/desktop/src/shared/components/slide-menu/slide-menu.component.html +++ b/desktop/src/shared/components/slide-menu/slide-menu.component.html @@ -1,5 +1,5 @@
- + diff --git a/desktop/src/shared/components/slide-menu/slide-menu.component.ts b/desktop/src/shared/components/slide-menu/slide-menu.component.ts index f7c254c71..59cfbd2fa 100644 --- a/desktop/src/shared/components/slide-menu/slide-menu.component.ts +++ b/desktop/src/shared/components/slide-menu/slide-menu.component.ts @@ -1,11 +1,11 @@ import { Component, ElementRef, EventEmitter, Input, OnInit, Output, TemplateRef } from '@angular/core' -import { ExtendedMenuItem, ExtendedMenuItemCommandEvent } from '../menu-item/menu-item.component' +import { MenuItem, MenuItemCommandEvent } from '../menu-item/menu-item.component' -export interface SlideMenuItem extends ExtendedMenuItem { +export interface SlideMenuItem extends MenuItem { command?: (event: SlideMenuItemCommandEvent) => void } -export interface SlideMenuItemCommandEvent extends ExtendedMenuItemCommandEvent { +export interface SlideMenuItemCommandEvent extends MenuItemCommandEvent { item?: SlideMenuItem parent?: SlideMenuItem level?: number @@ -51,9 +51,9 @@ export class SlideMenuComponent implements OnInit { for (const item of menu) { const command = item.command - if (item.menu?.length) { + if (item.subMenu?.length) { item.command = (event: SlideMenuItemCommandEvent) => { - this.menu = item.menu! + this.menu = item.subMenu! this.navigation.push(menu) event.parent = parent event.level = level @@ -61,7 +61,7 @@ export class SlideMenuComponent implements OnInit { this.onNext.emit(event) } - this.processMenu(item.menu, level + 1, item) + this.processMenu(item.subMenu, level + 1, item) } else { item.command = (event: SlideMenuItemCommandEvent) => { event.parent = parent diff --git a/desktop/src/shared/constants.ts b/desktop/src/shared/constants.ts index 5a2628fdf..bda08b458 100644 --- a/desktop/src/shared/constants.ts +++ b/desktop/src/shared/constants.ts @@ -1,4 +1,4 @@ -import { ExtendedMenuItem } from './components/menu-item/menu-item.component' +import { MenuItem } from './components/menu-item/menu-item.component' export const EVERY_MINUTE_CRON_TIME = '0 */1 * * * *' @@ -6,6 +6,6 @@ export const TWO_DIGITS_FORMATTER = new Intl.NumberFormat('en-US', { minimumInte export const THREE_DIGITS_FORMATTER = new Intl.NumberFormat('en-US', { minimumIntegerDigits: 3, minimumFractionDigits: 0, maximumFractionDigits: 0 }) export const ONE_DECIMAL_PLACE_FORMATTER = new Intl.NumberFormat('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) -export const SEPARATOR_MENU_ITEM: ExtendedMenuItem = { +export const SEPARATOR_MENU_ITEM: MenuItem = { separator: true, } diff --git a/desktop/src/shared/pipes/enum.pipe.ts b/desktop/src/shared/pipes/enum.pipe.ts index c84525f23..c5135e1fc 100644 --- a/desktop/src/shared/pipes/enum.pipe.ts +++ b/desktop/src/shared/pipes/enum.pipe.ts @@ -1,12 +1,14 @@ import { Pipe, PipeTransform } from '@angular/core' import { DARVState, TPPAState } from '../types/alignment.types' import { Constellation, SatelliteGroupType, SkyObjectType } from '../types/atlas.types' +import { AutoFocusState } from '../types/autofocus.type' import { CameraCaptureState } from '../types/camera.types' +import { FlatWizardState } from '../types/flat-wizard.types' import { GuideState } from '../types/guider.types' import { SCNRProtectionMethod } from '../types/image.types' export type EnumPipeKey = SCNRProtectionMethod | Constellation | SkyObjectType | SatelliteGroupType | - DARVState | TPPAState | GuideState | CameraCaptureState | 'ALL' | string + DARVState | TPPAState | GuideState | CameraCaptureState | FlatWizardState | AutoFocusState | 'ALL' | string @Pipe({ name: 'enum' }) export class EnumPipe implements PipeTransform { @@ -342,7 +344,14 @@ export class EnumPipe implements PipeTransform { 'CAPTURE_STARTED': undefined, 'EXPOSURE_STARTED': undefined, 'EXPOSURE_FINISHED': undefined, - 'CAPTURE_FINISHED': undefined + 'CAPTURE_FINISHED': undefined, + // Auto Focus. + 'CAPTURED': 'Captured', + 'MOVING': 'Moving', + 'EXPOSURED': 'Exposured', + 'ANALYSING': 'Analysing', + 'ANALYSED': 'Analysed', + 'CURVE_FITTED': 'Curve fitted', } transform(value: EnumPipeKey) { diff --git a/desktop/src/shared/services/api.service.ts b/desktop/src/shared/services/api.service.ts index 614cbdbfd..56db681fa 100644 --- a/desktop/src/shared/services/api.service.ts +++ b/desktop/src/shared/services/api.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core' import moment from 'moment' import { DARVStart, TPPAStart } from '../types/alignment.types' import { Angle, BodyPosition, CloseApproach, ComputedLocation, Constellation, DeepSkyObject, MinorPlanet, Satellite, SatelliteGroupType, SkyObjectType, Twilight } from '../types/atlas.types' +import { AutoFocusRequest } from '../types/autofocus.type' import { CalibrationFrame, CalibrationFrameGroup } from '../types/calibration.types' import { Camera, CameraStartCapture } from '../types/camera.types' import { Device, INDIProperty, INDISendProperty } from '../types/device.types' @@ -14,7 +15,7 @@ import { CoordinateInterpolation, DetectedStar, FOVCamera, FOVTelescope, ImageAn import { CelestialLocationType, Mount, MountRemoteControl, MountRemoteControlType, SlewRate, TrackMode } from '../types/mount.types' import { Rotator } from '../types/rotator.types' import { SequencePlan } from '../types/sequencer.types' -import { PlateSolverPreference } from '../types/settings.types' +import { PlateSolverOptions, StarDetectionOptions } from '../types/settings.types' import { FilterWheel } from '../types/wheel.types' import { HttpService } from './http.service' @@ -68,7 +69,6 @@ export class ApiService { return this.http.get(`cameras/${camera.id}/capturing`) } - // TODO: Rotator cameraSnoop(camera: Camera, equipment: Equipment) { const { mount, wheel, focuser, rotator } = equipment const query = this.http.query({ mount: mount?.name, wheel: wheel?.name, focuser: focuser?.name, rotator: rotator?.name }) @@ -382,6 +382,18 @@ export class ApiService { // INDI + indiDevice(device: T) { + return this.http.get(`indi/${device.id}`) + } + + indiDeviceConnect(device: Device) { + return this.http.put(`indi/${device.id}/connect`) + } + + indiDeviceDisconnect(device: Device) { + return this.http.put(`indi/${device.id}/disconnect`) + } + indiProperties(device: Device) { return this.http.get[]>(`indi/${device.id}/properties`) } @@ -519,9 +531,9 @@ export class ApiService { return this.http.get(`image/coordinate-interpolation?${query}`) } - detectStars(path: string) { + detectStars(path: string, starDetector: StarDetectionOptions) { const query = this.http.query({ path }) - return this.http.put(`image/detect-stars?${query}`) + return this.http.put(`star-detection?${query}`, starDetector) } imageHistogram(path: string, bitLength: number = 16) { @@ -628,11 +640,21 @@ export class ApiService { // SOLVER solveImage( - solver: PlateSolverPreference, path: string, blind: boolean, + solver: PlateSolverOptions, path: string, blind: boolean, centerRA: Angle, centerDEC: Angle, radius: Angle, ) { - const query = this.http.query({ ...solver, path, blind, centerRA, centerDEC, radius }) - return this.http.put(`plate-solver?${query}`) + const query = this.http.query({ path, blind, centerRA, centerDEC, radius }) + return this.http.put(`plate-solver?${query}`, solver) + } + + // AUTO FOCUS + + autoFocusStart(camera: Camera, focuser: Focuser, request: AutoFocusRequest) { + return this.http.put(`auto-focus/${camera.name}/${focuser.name}/start`, request) + } + + autoFocusStop(camera: Camera) { + return this.http.put(`auto-focus/${camera.name}/stop`) } // PREFERENCE diff --git a/desktop/src/shared/services/browser-window.service.ts b/desktop/src/shared/services/browser-window.service.ts index 5b2e9d4f5..3b71ad203 100644 --- a/desktop/src/shared/services/browser-window.service.ts +++ b/desktop/src/shared/services/browser-window.service.ts @@ -61,7 +61,7 @@ export class BrowserWindowService { } openGuider(options: OpenWindowOptions = {}) { - Object.assign(options, { icon: 'guider', width: 425, height: 438 }) + Object.assign(options, { icon: 'guider', width: 440, height: 455 }) this.openWindow({ ...options, id: 'guider', path: 'guider', data: undefined }) } @@ -104,6 +104,11 @@ export class BrowserWindowService { this.openWindow({ ...options, id: 'sequencer', path: 'sequencer', data: undefined }) } + openAutoFocus(options: OpenWindowOptions = {}) { + Object.assign(options, { icon: 'auto-focus', width: 425, height: 420 }) + this.openWindow({ ...options, id: 'auto-focus', path: 'auto-focus', data: undefined }) + } + openFlatWizard(options: OpenWindowOptions = {}) { Object.assign(options, { icon: 'star', width: 385, height: 370 }) this.openWindow({ ...options, id: 'flat-wizard', path: 'flat-wizard', data: undefined }) diff --git a/desktop/src/shared/services/electron.service.ts b/desktop/src/shared/services/electron.service.ts index 8c7cbea52..6c343e2d9 100644 --- a/desktop/src/shared/services/electron.service.ts +++ b/desktop/src/shared/services/electron.service.ts @@ -22,6 +22,7 @@ import { Mount } from '../types/mount.types' import { Rotator } from '../types/rotator.types' import { SequencerEvent } from '../types/sequencer.types' import { FilterWheel, WheelRenamed } from '../types/wheel.types' +import { AutoFocusEvent } from '../types/autofocus.type' type EventMappedType = { 'DEVICE.PROPERTY_CHANGED': INDIMessageEvent @@ -74,6 +75,7 @@ type EventMappedType = { 'WINDOW.CLOSE': CloseWindow 'WHEEL.RENAMED': WheelRenamed 'ROI.SELECTED': ROISelected + 'AUTO_FOCUS.ELAPSED': AutoFocusEvent } @Injectable({ providedIn: 'root' }) diff --git a/desktop/src/shared/services/preference.service.ts b/desktop/src/shared/services/preference.service.ts index 98b902672..92808fee0 100644 --- a/desktop/src/shared/services/preference.service.ts +++ b/desktop/src/shared/services/preference.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core' import { SkyAtlasPreference } from '../../app/atlas/atlas.component' import { AlignmentPreference, EMPTY_ALIGNMENT_PREFERENCE } from '../types/alignment.types' import { EMPTY_LOCATION, Location } from '../types/atlas.types' +import { AutoFocusPreference, EMPTY_AUTO_FOCUS_PREFERENCE } from '../types/autofocus.type' import { CalibrationPreference } from '../types/calibration.types' import { Camera, CameraPreference, CameraStartCapture, EMPTY_CAMERA_PREFERENCE } from '../types/camera.types' import { Device } from '../types/device.types' @@ -9,7 +10,7 @@ import { Focuser, FocuserPreference } from '../types/focuser.types' import { ConnectionDetails, Equipment, HomePreference } from '../types/home.types' import { EMPTY_IMAGE_PREFERENCE, FOV, ImagePreference } from '../types/image.types' import { Rotator, RotatorPreference } from '../types/rotator.types' -import { EMPTY_PLATE_SOLVER_PREFERENCE, PlateSolverPreference, PlateSolverType } from '../types/settings.types' +import { EMPTY_PLATE_SOLVER_OPTIONS, EMPTY_STAR_DETECTION_OPTIONS, PlateSolverOptions, PlateSolverType, StarDetectionOptions, StarDetectorType } from '../types/settings.types' import { FilterWheel, WheelPreference } from '../types/wheel.types' import { LocalStorageService } from './local-storage.service' @@ -63,8 +64,16 @@ export class PreferenceService { return new PreferenceData(this.storage, `camera.${camera.name}.tppa`, () => this.cameraPreference(camera).get()) } - plateSolverPreference(type: PlateSolverType) { - return new PreferenceData(this.storage, `plateSolver.${type}`, () => { ...EMPTY_PLATE_SOLVER_PREFERENCE, type }) + cameraStartCaptureForAutoFocus(camera: Camera) { + return new PreferenceData(this.storage, `camera.${camera.name}.autoFocus`, () => this.cameraPreference(camera).get()) + } + + plateSolverOptions(type: PlateSolverType) { + return new PreferenceData(this.storage, `plateSolver.${type}`, () => { ...EMPTY_PLATE_SOLVER_OPTIONS, type }) + } + + starDetectionOptions(type: StarDetectorType) { + return new PreferenceData(this.storage, `starDetection.${type}`, () => { ...EMPTY_STAR_DETECTION_OPTIONS, type }) } equipmentForDevice(device: Device) { @@ -92,4 +101,5 @@ export class PreferenceService { readonly alignmentPreference = new PreferenceData(this.storage, 'alignment', () => structuredClone(EMPTY_ALIGNMENT_PREFERENCE)) readonly imageFOVs = new PreferenceData(this.storage, 'image.fovs', () => []) readonly calibrationPreference = new PreferenceData(this.storage, 'calibration', () => {}) -} \ No newline at end of file + readonly autoFocusPreference = new PreferenceData(this.storage, 'autoFocus', () => structuredClone(EMPTY_AUTO_FOCUS_PREFERENCE)) +} diff --git a/desktop/src/shared/types/alignment.types.ts b/desktop/src/shared/types/alignment.types.ts index 3b48be378..19b4ddfd0 100644 --- a/desktop/src/shared/types/alignment.types.ts +++ b/desktop/src/shared/types/alignment.types.ts @@ -1,7 +1,7 @@ import { Angle } from './atlas.types' import { Camera, CameraCaptureEvent, CameraStartCapture } from './camera.types' import { GuideDirection } from './guider.types' -import { PlateSolverPreference, PlateSolverType } from './settings.types' +import { PlateSolverOptions, PlateSolverType } from './settings.types' export type Hemisphere = 'NORTHERN' | 'SOUTHERN' @@ -52,7 +52,7 @@ export interface DARVEvent extends MessageEvent { export interface TPPAStart { capture: CameraStartCapture - plateSolver: PlateSolverPreference + plateSolver: PlateSolverOptions startFromCurrentPosition: boolean compensateRefraction: boolean stopTrackingWhenDone: boolean diff --git a/desktop/src/shared/types/api.types.ts b/desktop/src/shared/types/api.types.ts index 65733134a..56dd57faa 100644 --- a/desktop/src/shared/types/api.types.ts +++ b/desktop/src/shared/types/api.types.ts @@ -29,6 +29,8 @@ export const API_EVENT_TYPES = [ 'GUIDER.CONNECTED', 'GUIDER.DISCONNECTED', 'GUIDER.UPDATED', 'GUIDER.STEPPED', 'GUIDER.MESSAGE_RECEIVED', // Polar Alignment. 'DARV_ALIGNMENT.ELAPSED', + // Auto Focus. + 'AUTO_FOCUS.ELAPSED', ] as const export type ApiEventType = (typeof API_EVENT_TYPES)[number] diff --git a/desktop/src/shared/types/app.types.ts b/desktop/src/shared/types/app.types.ts index 59325b479..633e583b5 100644 --- a/desktop/src/shared/types/app.types.ts +++ b/desktop/src/shared/types/app.types.ts @@ -1,17 +1,6 @@ -import { MenuItem } from 'primeng/api' -import { CheckboxChangeEvent } from 'primeng/checkbox' import { MessageEvent } from './api.types' -export interface CheckableMenuItem extends MenuItem { - checked: boolean -} - -export interface ToggleableMenuItem extends MenuItem { - toggleable: boolean - toggled: boolean - - toggle: (event: CheckboxChangeEvent) => void -} +export type Severity = 'success' | 'info' | 'warning' | 'danger' export interface NotificationEvent extends MessageEvent { type: string diff --git a/desktop/src/shared/types/autofocus.type.ts b/desktop/src/shared/types/autofocus.type.ts new file mode 100644 index 000000000..4b84c4c4a --- /dev/null +++ b/desktop/src/shared/types/autofocus.type.ts @@ -0,0 +1,93 @@ +import { Point } from 'electron' +import { CameraCaptureEvent, CameraStartCapture } from './camera.types' +import { EMPTY_STAR_DETECTION_OPTIONS, StarDetectionOptions } from './settings.types' + +export type AutoFocusState = 'IDLE' | 'MOVING' | 'EXPOSURING' | 'EXPOSURED' | 'ANALYSING' | 'ANALYSED' | 'CURVE_FITTED' | 'FAILED' | 'FINISHED' + +export type AutoFocusFittingMode = 'TRENDLINES' | 'PARABOLIC' | 'TREND_PARABOLIC' | 'HYPERBOLIC' | 'TREND_HYPERBOLIC' + +export type BacklashCompensationMode = 'NONE' | 'ABSOLUTE' | 'OVERSHOOT' + +export interface BacklashCompensation { + mode: BacklashCompensationMode + backlashIn: number + backlashOut: number +} + +export interface AutoFocusRequest { + fittingMode: AutoFocusFittingMode + capture: CameraStartCapture + rSquaredThreshold: number + backlashCompensation: BacklashCompensation + initialOffsetSteps: number + stepSize: number + totalNumberOfAttempts: number + starDetector: StarDetectionOptions +} + +export interface AutoFocusPreference extends Omit { } + +export const EMPTY_AUTO_FOCUS_PREFERENCE: AutoFocusPreference = { + fittingMode: 'HYPERBOLIC', + rSquaredThreshold: 0.5, + initialOffsetSteps: 4, + stepSize: 100, + totalNumberOfAttempts: 1, + backlashCompensation: { + mode: 'NONE', + backlashIn: 0, + backlashOut: 0 + }, + starDetector: EMPTY_STAR_DETECTION_OPTIONS, +} + +export interface Curve { + minimum: Point + rSquared: number +} + +export interface Plottable { + points: Point[] +} + +export interface HyperbolicCurve extends Curve, Plottable { + a: number + b: number + p: number +} + +export interface ParabolicCurve extends Curve, Plottable { +} + +export interface Line extends Plottable { + slope: number + intercept: number + rSquared: number +} + +export interface TrendLineCurve extends Curve { + left: Line + right: Line + intersection: Point +} + +export interface CurveChart { + predictedFocusPoint?: Point + minX: number + maxX: number + minY: number + maxY: number + trendLine?: TrendLineCurve + parabolic?: ParabolicCurve + hyperbolic?: HyperbolicCurve +} + +export interface AutoFocusEvent { + state: AutoFocusState + focusPoint?: Point + determinedFocusPoint?: Point + starCount: number + starHFD: number + chart?: CurveChart + capture?: CameraCaptureEvent +} diff --git a/desktop/src/shared/types/auxiliary.types.ts b/desktop/src/shared/types/auxiliary.types.ts index 549799e4f..eca875e7d 100644 --- a/desktop/src/shared/types/auxiliary.types.ts +++ b/desktop/src/shared/types/auxiliary.types.ts @@ -4,3 +4,7 @@ export interface Thermometer extends Device { hasThermometer: boolean temperature: number } + +export function isThermometer(device?: Device): device is Thermometer { + return !!device && 'temperature' in device +} diff --git a/desktop/src/shared/types/calculator.types.ts b/desktop/src/shared/types/calculator.types.ts index b82d5da25..ca490510f 100644 --- a/desktop/src/shared/types/calculator.types.ts +++ b/desktop/src/shared/types/calculator.types.ts @@ -6,6 +6,7 @@ export interface CalculatorOperand { value?: number minFractionDigits?: number maxFractionDigits?: number + min?: number } export interface CalculatorFormula { diff --git a/desktop/src/shared/types/camera.types.ts b/desktop/src/shared/types/camera.types.ts index e97040128..5d48be72f 100644 --- a/desktop/src/shared/types/camera.types.ts +++ b/desktop/src/shared/types/camera.types.ts @@ -1,9 +1,9 @@ import { MessageEvent } from './api.types' import { Thermometer } from './auxiliary.types' -import { CompanionDevice, Device, PropertyState } from './device.types' +import { CompanionDevice, Device, PropertyState, isCompanionDevice } from './device.types' import { GuideOutput } from './guider.types' -export type CameraDialogMode = 'CAPTURE' | 'SEQUENCER' | 'FLAT_WIZARD' | 'TPPA' | 'DARV' +export type CameraDialogMode = 'CAPTURE' | 'SEQUENCER' | 'FLAT_WIZARD' | 'TPPA' | 'DARV' | 'AUTO_FOCUS' export type FrameType = 'LIGHT' | 'DARK' | 'FLAT' | 'BIAS' @@ -266,3 +266,11 @@ export const EMPTY_CAMERA_CAPTURE_INFO: CameraCaptureInfo = { progress: 0, count: 0, } + +export function isCamera(device?: Device): device is Camera { + return !!device && 'exposuring' in device +} + +export function isGuideHead(device?: Device): device is GuideHead { + return isCamera(device) && isCompanionDevice(device) && !!device.main +} diff --git a/desktop/src/shared/types/device.types.ts b/desktop/src/shared/types/device.types.ts index 934a9509f..b26f23daa 100644 --- a/desktop/src/shared/types/device.types.ts +++ b/desktop/src/shared/types/device.types.ts @@ -8,6 +8,8 @@ export type INDIPropertyType = 'NUMBER' | 'SWITCH' | 'TEXT' export type SwitchRule = 'ONE_OF_MANY' | 'AT_MOST_ONE' | 'ANY_OF_MANY' +export type DeviceType = 'CAMERA' | 'MOUNT' | 'WHEEL' | 'FOCUSER' | 'ROTATOR' | 'GPS' | 'DOME' | 'SWITCH' + export interface Device { readonly sender: string readonly id: string @@ -57,3 +59,7 @@ export interface INDIDeviceMessage { device?: Device message: string } + +export function isCompanionDevice(device?: T | CompanionDevice): device is CompanionDevice { + return !!device && 'main' in device +} diff --git a/desktop/src/shared/types/focuser.types.ts b/desktop/src/shared/types/focuser.types.ts index 50bf8c5ea..5f05f8302 100644 --- a/desktop/src/shared/types/focuser.types.ts +++ b/desktop/src/shared/types/focuser.types.ts @@ -37,3 +37,7 @@ export interface FocuserPreference { stepsRelative?: number stepsAbsolute?: number } + +export function isFocuser(device?: Device): device is Focuser { + return !!device && 'maxPosition' in device +} diff --git a/desktop/src/shared/types/home.types.ts b/desktop/src/shared/types/home.types.ts index fd22c33fc..6c6195728 100644 --- a/desktop/src/shared/types/home.types.ts +++ b/desktop/src/shared/types/home.types.ts @@ -1,11 +1,12 @@ import { Camera } from './camera.types' +import { DeviceType } from './device.types' import { Focuser } from './focuser.types' import { Mount } from './mount.types' import { Rotator } from './rotator.types' import { FilterWheel } from './wheel.types' -export type HomeWindowType = 'CAMERA' | 'MOUNT' | 'GUIDER' | 'WHEEL' | 'FOCUSER' | 'DOME' | 'ROTATOR' | 'SWITCH' | - 'SKY_ATLAS' | 'ALIGNMENT' | 'SEQUENCER' | 'IMAGE' | 'FRAMING' | 'INDI' | 'SETTINGS' | 'CALCULATOR' | 'ABOUT' | 'FLAT_WIZARD' +export type HomeWindowType = DeviceType | 'GUIDER' | 'SKY_ATLAS' | 'ALIGNMENT' | 'SEQUENCER' | 'IMAGE' | 'FRAMING' | 'INDI' | + 'SETTINGS' | 'CALCULATOR' | 'ABOUT' | 'FLAT_WIZARD' | 'AUTO_FOCUS' export const CONNECTION_TYPES = ['INDI', 'ALPACA'] as const diff --git a/desktop/src/shared/types/image.types.ts b/desktop/src/shared/types/image.types.ts index 37f87cc93..6fb7c9c74 100644 --- a/desktop/src/shared/types/image.types.ts +++ b/desktop/src/shared/types/image.types.ts @@ -8,7 +8,7 @@ export type ImageChannel = 'RED' | 'GREEN' | 'BLUE' | 'GRAY' export const SCNR_PROTECTION_METHODS = ['MAXIMUM_MASK', 'ADDITIVE_MASK', 'AVERAGE_NEUTRAL', 'MAXIMUM_NEUTRAL', 'MINIMUM_NEUTRAL'] as const export type SCNRProtectionMethod = (typeof SCNR_PROTECTION_METHODS)[number] -export type ImageSource = 'FRAMING' | 'PATH' | 'CAMERA' | 'FLAT_WIZARD' +export type ImageSource = 'FRAMING' | 'PATH' | 'CAMERA' | 'FLAT_WIZARD' | 'SEQUENCER' | 'ALIGNMENT' | 'AUTO_FOCUS' export type ImageFormat = 'FITS' | 'XISF' | 'PNG' | 'JPG' diff --git a/desktop/src/shared/types/mount.types.ts b/desktop/src/shared/types/mount.types.ts index 7fc7611af..afc1c453f 100644 --- a/desktop/src/shared/types/mount.types.ts +++ b/desktop/src/shared/types/mount.types.ts @@ -1,4 +1,5 @@ import { Angle, EquatorialCoordinate } from './atlas.types' +import { Device } from './device.types' import { GPS } from './gps.types' import { GuideOutput } from './guider.types' @@ -95,3 +96,7 @@ export interface MountRemoteControlDialog { port: number data: MountRemoteControl[] } + +export function isMount(device?: Device): device is Mount { + return !!device && 'tracking' in device +} diff --git a/desktop/src/shared/types/rotator.types.ts b/desktop/src/shared/types/rotator.types.ts index 4348ce342..daebb23a2 100644 --- a/desktop/src/shared/types/rotator.types.ts +++ b/desktop/src/shared/types/rotator.types.ts @@ -33,3 +33,7 @@ export const EMPTY_ROTATOR: Rotator = { export interface RotatorPreference { angle?: number } + +export function isRotator(device?: Device): device is Rotator { + return !!device && 'angle' in device +} diff --git a/desktop/src/shared/types/settings.types.ts b/desktop/src/shared/types/settings.types.ts index d3eeacabb..b427182f6 100644 --- a/desktop/src/shared/types/settings.types.ts +++ b/desktop/src/shared/types/settings.types.ts @@ -1,9 +1,6 @@ - export type PlateSolverType = 'ASTROMETRY_NET' | 'ASTROMETRY_NET_ONLINE' | 'ASTAP' -export const DEFAULT_SOLVER_TYPES: PlateSolverType[] = ['ASTROMETRY_NET_ONLINE', 'ASTAP'] - -export interface PlateSolverPreference { +export interface PlateSolverOptions { type: PlateSolverType executablePath: string downsampleFactor: number @@ -12,7 +9,7 @@ export interface PlateSolverPreference { timeout: number } -export const EMPTY_PLATE_SOLVER_PREFERENCE: PlateSolverPreference = { +export const EMPTY_PLATE_SOLVER_OPTIONS: PlateSolverOptions = { type: 'ASTAP', executablePath: '', downsampleFactor: 0, @@ -20,3 +17,17 @@ export const EMPTY_PLATE_SOLVER_PREFERENCE: PlateSolverPreference = { apiKey: '', timeout: 600, } + +export type StarDetectorType = 'ASTAP' + +export interface StarDetectionOptions { + type: StarDetectorType + executablePath: string + timeout: number +} + +export const EMPTY_STAR_DETECTION_OPTIONS: StarDetectionOptions = { + type: 'ASTAP', + executablePath: '', + timeout: 600, +} diff --git a/desktop/src/shared/types/wheel.types.ts b/desktop/src/shared/types/wheel.types.ts index bd4928393..d60f738c8 100644 --- a/desktop/src/shared/types/wheel.types.ts +++ b/desktop/src/shared/types/wheel.types.ts @@ -42,4 +42,8 @@ export interface FilterSlot { export interface WheelRenamed { wheel: FilterWheel filter: FilterSlot -} \ No newline at end of file +} + +export function isFilterWheel(device?: Device): device is FilterWheel { + return !!device && 'count' in device +} diff --git a/desktop/src/shared/utils/comparators.ts b/desktop/src/shared/utils/comparators.ts index edf740cc9..ba83ab618 100644 --- a/desktop/src/shared/utils/comparators.ts +++ b/desktop/src/shared/utils/comparators.ts @@ -5,6 +5,7 @@ export type Comparator = (a: T, b: T) => number export const textComparator: Comparator = (a: string, b: string) => a.localeCompare(b) export const numberComparator: Comparator = (a: number, b: number) => a - b export const deviceComparator: Comparator = (a: Device, b: Device) => textComparator(a.name, b.name) +export const numericTextComparator: Comparator = (a: string, b: string) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) export function negateComparator(comparator: Comparator): Comparator { return (a, b) => -comparator(a, b) diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt index 3f0f5c33c..b85a3385b 100644 --- a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt @@ -56,7 +56,7 @@ data class ThreePointPolarAlignment( compensateRefraction: Boolean = false, cancellationToken: CancellationToken = CancellationToken.NONE, ): ThreePointPolarAlignmentResult { - if (cancellationToken.isDone) { + if (cancellationToken.isCancelled) { return Cancelled } @@ -66,7 +66,7 @@ data class ThreePointPolarAlignment( return NoPlateSolution(e) } - if (cancellationToken.isDone) { + if (cancellationToken.isCancelled) { return Cancelled } else if (!solution.solved) { return NoPlateSolution(null) diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMDevice.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMDevice.kt index 6a58bb809..6a044aff5 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMDevice.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMDevice.kt @@ -157,9 +157,9 @@ abstract class ASCOMDevice : Device, Resettable { refresh(stopwatch.elapsedSeconds) } - val delayTime = 2000L - elapsedTime + val delayTime = 1500L - elapsedTime - if (delayTime > 1L) { + if (delayTime >= 10L) { sleep(delayTime) } } diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/cameras/ASCOMCamera.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/cameras/ASCOMCamera.kt index 14c3fb6a6..84d26fa14 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/cameras/ASCOMCamera.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/cameras/ASCOMCamera.kt @@ -15,6 +15,8 @@ import nebulosa.image.format.HeaderCard import nebulosa.indi.device.Device import nebulosa.indi.device.camera.* import nebulosa.indi.device.camera.Camera.Companion.NANO_TO_SECONDS +import nebulosa.indi.device.filterwheel.FilterWheel +import nebulosa.indi.device.focuser.Focuser import nebulosa.indi.device.guide.GuideOutputPulsingChanged import nebulosa.indi.device.mount.Mount import nebulosa.indi.protocol.INDIProtocol @@ -679,10 +681,21 @@ data class ASCOMCamera( header.add(FitsKeyword.EQUINOX, 2000) } + val focuser = snoopedDevices.firstOrNull { it is Focuser } as? Focuser + + focuser?.also { + header.add(FitsKeyword.FOCUSPOS, it.position) + } + + val wheel = snoopedDevices.firstOrNull { it is FilterWheel } as? FilterWheel + + wheel?.also { + header.add(FitsKeyword.FILTER, it.names.getOrNull(it.position) ?: "Filter #${it.position}") + } + fitsKeywords.forEach(header::add) val hdu = BasicImageHdu(width, height, numberOfChannels, header, data) - val image = Fits() image.add(hdu) diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/focusers/ASCOMFocuser.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/focusers/ASCOMFocuser.kt index 267b0ca0b..14739c573 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/focusers/ASCOMFocuser.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/focusers/ASCOMFocuser.kt @@ -29,26 +29,28 @@ data class ASCOMFocuser( @Volatile final override var hasThermometer = false @Volatile final override var temperature = 0.0 + @Volatile private var internalMoving = false + override val snoopedDevices = emptyList() override fun moveFocusIn(steps: Int) { - if (canAbsoluteMove) { - service.move(device.number, position + steps).doRequest() + internalMoving = if (canAbsoluteMove) { + service.move(device.number, position + steps).doRequest { } } else { - service.move(device.number, steps).doRequest() + service.move(device.number, steps).doRequest { } } } override fun moveFocusOut(steps: Int) { - if (canAbsoluteMove) { - service.move(device.number, position - steps).doRequest() + internalMoving = if (canAbsoluteMove) { + service.move(device.number, position - steps).doRequest { } } else { - service.move(device.number, -steps).doRequest() + service.move(device.number, -steps).doRequest { } } } override fun moveFocusTo(steps: Int) { - service.move(device.number, steps).doRequest() + internalMoving = service.move(device.number, steps).doRequest { } } override fun abortFocus() { @@ -74,6 +76,7 @@ data class ASCOMFocuser( super.reset() moving = false + internalMoving = false position = 0 canAbsoluteMove = false canRelativeMove = false @@ -124,9 +127,10 @@ data class ASCOMFocuser( private fun processMoving() { service.isMoving(device.number).doRequest { - if (it.value != moving) { - moving = it.value + val value = it.value || internalMoving + if (value != moving) { + moving = value sender.fireOnEventReceived(FocuserMovingChanged(this)) } } @@ -136,8 +140,11 @@ data class ASCOMFocuser( service.position(device.number).doRequest { if (it.value != position) { position = it.value - sender.fireOnEventReceived(FocuserPositionChanged(this)) + } else if (internalMoving && moving) { + moving = false + internalMoving = false + sender.fireOnEventReceived(FocuserMovingChanged(this)) } } } @@ -157,4 +164,11 @@ data class ASCOMFocuser( } } } + + override fun toString() = "Focuser(name=$name, moving=$moving, position=$position," + + " canAbsoluteMove=$canAbsoluteMove, canRelativeMove=$canRelativeMove," + + " canAbort=$canAbort, canReverse=$canReverse, reversed=$reversed," + + " canSync=$canSync, hasBacklash=$hasBacklash," + + " maxPosition=$maxPosition, hasThermometer=$hasThermometer," + + " temperature=$temperature)" } diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/mounts/ASCOMMount.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/mounts/ASCOMMount.kt index 4f7dce29e..dc79d320a 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/mounts/ASCOMMount.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/mounts/ASCOMMount.kt @@ -474,6 +474,15 @@ data class ASCOMMount( } } + override fun toString() = "Mount(name=$name, connected=$connected, slewing=$slewing, tracking=$tracking," + + " parking=$parking, parked=$parked, canAbort=$canAbort," + + " canSync=$canSync, canPark=$canPark, slewRates=$slewRates," + + " slewRate=$slewRate, mountType=$mountType, trackModes=$trackModes," + + " trackMode=$trackMode, pierSide=$pierSide, guideRateWE=$guideRateWE," + + " guideRateNS=$guideRateNS, rightAscension=$rightAscension," + + " declination=$declination, canPulseGuide=$canPulseGuide," + + " pulseGuiding=$pulseGuiding)" + companion object { private const val EPSILON = 1 / 36000.0 * DEG2RAD diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/wheels/ASCOMFilterWheel.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/wheels/ASCOMFilterWheel.kt index d327dcc32..413f7eee5 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/wheels/ASCOMFilterWheel.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/wheels/ASCOMFilterWheel.kt @@ -88,4 +88,6 @@ data class ASCOMFilterWheel( } } } + + override fun toString() = "FilterWheel(name=$name, slotCount=$count, position=$position, moving=$moving)" } diff --git a/nebulosa-astap/src/main/kotlin/nebulosa/astap/star/detection/AstapStarDetector.kt b/nebulosa-astap/src/main/kotlin/nebulosa/astap/star/detection/AstapStarDetector.kt index 87306d63d..6c25aa07b 100644 --- a/nebulosa-astap/src/main/kotlin/nebulosa/astap/star/detection/AstapStarDetector.kt +++ b/nebulosa-astap/src/main/kotlin/nebulosa/astap/star/detection/AstapStarDetector.kt @@ -21,7 +21,7 @@ class AstapStarDetector(path: Path) : StarDetector { val arguments = mutableMapOf() arguments["-f"] = input - arguments["-z"] = 2 + arguments["-z"] = 0 arguments["-extract"] = 0 val process = executor.execute(arguments, workingDir = input.parent) diff --git a/nebulosa-common/src/test/kotlin/CancellationTokenTest.kt b/nebulosa-common/src/test/kotlin/CancellationTokenTest.kt index 16f6425a0..84688e538 100644 --- a/nebulosa-common/src/test/kotlin/CancellationTokenTest.kt +++ b/nebulosa-common/src/test/kotlin/CancellationTokenTest.kt @@ -1,4 +1,5 @@ import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.booleans.shouldBeFalse import io.kotest.matchers.booleans.shouldBeTrue import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe @@ -15,6 +16,7 @@ class CancellationTokenTest : StringSpec() { token.cancel(false) token.get() shouldBe source source shouldBe CancellationSource.Cancel(false) + token.isCancelled.shouldBeTrue() token.isDone.shouldBeTrue() } "cancel may interrupt if running" { @@ -24,6 +26,7 @@ class CancellationTokenTest : StringSpec() { token.cancel() token.get() shouldBe source source shouldBe CancellationSource.Cancel(true) + token.isCancelled.shouldBeTrue() token.isDone.shouldBeTrue() } "close" { @@ -33,25 +36,28 @@ class CancellationTokenTest : StringSpec() { token.close() token.get() shouldBe source source shouldBe CancellationSource.Close + token.isCancelled.shouldBeTrue() token.isDone.shouldBeTrue() } - "listen" { + "listen after cancel" { var source: CancellationSource? = null val token = CancellationToken() token.cancel() token.listen { source = it } token.get() shouldBe CancellationSource.Cancel(true) source shouldBe CancellationSource.Listen + token.isCancelled.shouldBeTrue() token.isDone.shouldBeTrue() } "none" { var source: CancellationSource? = null val token = CancellationToken.NONE - token.isDone.shouldBeTrue() token.listen { source = it } token.cancel() token.get() shouldBe CancellationSource.None source.shouldBeNull() + token.isCancelled.shouldBeFalse() + token.isDone.shouldBeTrue() } } } diff --git a/nebulosa-curve-fitting/build.gradle.kts b/nebulosa-curve-fitting/build.gradle.kts new file mode 100644 index 000000000..2878840c9 --- /dev/null +++ b/nebulosa-curve-fitting/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + kotlin("jvm") + id("maven-publish") +} + +dependencies { + api(libs.apache.math) + implementation(project(":nebulosa-log")) + testImplementation(project(":nebulosa-test")) +} + +publishing { + publications { + create("pluginMaven") { + from(components["java"]) + } + } +} diff --git a/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/Curve.kt b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/Curve.kt new file mode 100644 index 000000000..d0ef91549 --- /dev/null +++ b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/Curve.kt @@ -0,0 +1,22 @@ +package nebulosa.curve.fitting + +import org.apache.commons.math3.analysis.UnivariateFunction + +fun interface Curve : UnivariateFunction { + + operator fun invoke(x: Double) = value(x) + + companion object { + + @JvmStatic + fun DoubleArray.curvePoints(): Collection { + val points = ArrayList(size / 2) + + for (i in indices step 2) { + points.add(CurvePoint(this[i], this[i + 1])) + } + + return points + } + } +} diff --git a/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/CurveFitting.kt b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/CurveFitting.kt new file mode 100644 index 000000000..cf63f5f62 --- /dev/null +++ b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/CurveFitting.kt @@ -0,0 +1,10 @@ +package nebulosa.curve.fitting + +import nebulosa.curve.fitting.Curve.Companion.curvePoints + +fun interface CurveFitting { + + fun calculate(points: Collection): T + + fun calculate(vararg points: Double) = calculate(points.curvePoints()) +} diff --git a/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/CurvePoint.kt b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/CurvePoint.kt new file mode 100644 index 000000000..54cca627f --- /dev/null +++ b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/CurvePoint.kt @@ -0,0 +1,35 @@ +package nebulosa.curve.fitting + +import org.apache.commons.math3.fitting.WeightedObservedPoint + +class CurvePoint(x: Double, y: Double) : WeightedObservedPoint(1.0, x, y) { + + operator fun component1() = x + + operator fun component2() = y + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CurvePoint) return false + + if (x != other.x) return false + + return y == other.y + } + + override fun hashCode(): Int { + var result = x.hashCode() + result = 31 * result + y.hashCode() + return result + } + + override fun toString() = "CurvePoint(x=$x, y=$y)" + + companion object { + + @JvmStatic val ZERO = CurvePoint(0.0, 0.0) + + @JvmStatic + infix fun CurvePoint.midPoint(point: CurvePoint) = CurvePoint((x + point.x) / 2, (y + point.y) / 2) + } +} diff --git a/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/FittedCurve.kt b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/FittedCurve.kt new file mode 100644 index 000000000..916e220bd --- /dev/null +++ b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/FittedCurve.kt @@ -0,0 +1,8 @@ +package nebulosa.curve.fitting + +interface FittedCurve : Curve { + + val minimum: CurvePoint + + val rSquared: Double +} diff --git a/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/HyperbolicFitting.kt b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/HyperbolicFitting.kt new file mode 100644 index 000000000..d711430d1 --- /dev/null +++ b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/HyperbolicFitting.kt @@ -0,0 +1,120 @@ +package nebulosa.curve.fitting + +import kotlin.math.* + +// https://bitbucket.org/Isbeorn/nina/src/master/NINA.Core.WPF/Utility/AutoFocus/HyperbolicFitting.cs + +data object HyperbolicFitting : CurveFitting { + + data class Curve( + @JvmField val a: Double, + @JvmField val b: Double, + @JvmField val p: Double, + override val minimum: CurvePoint, + @JvmField val points: Collection, + ) : FittedCurve { + + override val rSquared by lazy { RSquared.calculate(points, this) } + + override fun value(x: Double) = a * cosh(asinh((p - x) / b)) + } + + override fun calculate(points: Collection): Curve { + var lowestError = Double.MAX_VALUE + + val nonZeroPoints = points.filter { it.y >= 0.1 } + + if (nonZeroPoints.isEmpty()) { + throw IllegalArgumentException("No non-zero points in curve. No fit can be calculated.") + } + + val lowestPoint = nonZeroPoints.minBy { it.y } + val highestPoint = nonZeroPoints.maxBy { it.y } + + var highestPosition = highestPoint.x + val highestHfr = highestPoint.y + val lowestPosition = lowestPoint.x + val lowestHfr = lowestPoint.y + var oldError = Double.MAX_VALUE + + // Always go up + if (highestPosition < lowestPosition) { + highestPosition = 2 * lowestPosition - highestPosition + } + + // Get good starting values for a, b and p. + var a = lowestHfr // a is near the lowest HFR value + // Alternative hyperbola formula: sqr(y)/sqr(a)-sqr(x)/sqr(b)=1 ==> sqr(b)=sqr(x)*sqr(a)/(sqr(y)-sqr(a) + var b = sqrt((highestPosition - lowestPosition) * (highestPosition - lowestPosition) * a * a / (highestHfr * highestHfr - a * a)) + var p = lowestPosition + + var iterationCycles = 0 // How many cycles where used for curve fitting + + var aRange = a + var bRange = b + var pRange = highestPosition - lowestPosition // Large steps since slope could contain some error + + if (aRange.isNaN() || bRange.isNaN() || aRange == 0.0 || bRange == 0.0 || pRange == 0.0) { + throw IllegalArgumentException("Not enough valid data points to fit a curve.") + } + + do { + val p0 = p + val b0 = b + val a0 = a + + // Reduce range by 50% + aRange *= 0.5 + bRange *= 0.5 + pRange *= 0.5 + + // Start value + var p1 = p0 - pRange + + // Position loop + while (p1 <= p0 + pRange) { + var a1 = a0 - aRange + + while (a1 <= a0 + aRange) { + var b1 = b0 - bRange + + while (b1 <= b0 + bRange) { + val error1 = scaledErrorHyperbola(nonZeroPoints, p1, a1, b1) + + // Better position found + if (error1 < lowestError) { + oldError = lowestError + lowestError = error1 + + // Best value up to now + a = a1 + b = b1 + p = p1 + } + + // do 20 steps within range, many steps guarantees convergence + b1 += bRange * 0.1 + } + + a1 += aRange * 0.1 + } + + p1 += pRange * 0.1 + } + } while (oldError - lowestError >= 0.0001 && lowestError > 0.0001 && ++iterationCycles < 30) + + val minimum = CurvePoint(round(p), a) + + return Curve(a, b, p, minimum, nonZeroPoints) + } + + private fun scaledErrorHyperbola(points: Collection, perfectFocusPosition: Double, a: Double, b: Double): Double { + return sqrt(points.sumOf { (hyperbolicFittingHfrCalc(it.x, perfectFocusPosition, a, b) - it.y).pow(2.0) }) + } + + private fun hyperbolicFittingHfrCalc(position: Double, perfectFocusPosition: Double, a: Double, b: Double): Double { + val x = perfectFocusPosition - position + val t = asinh(x / b) // Calculate t-position in hyperbola + return a * cosh(t) // Convert t-position to y/hfd value + } +} diff --git a/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/LinearCurve.kt b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/LinearCurve.kt new file mode 100644 index 000000000..e19659785 --- /dev/null +++ b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/LinearCurve.kt @@ -0,0 +1,19 @@ +package nebulosa.curve.fitting + +interface LinearCurve : Curve { + + val slope: Double + + val intercept: Double + + val rSquared: Double + + fun intersect(line: TrendLine): CurvePoint { + if (slope == line.slope) return CurvePoint.ZERO + + val x = (line.intercept - intercept) / (slope - line.slope) + val y = slope * x + intercept + + return CurvePoint(x, y) + } +} diff --git a/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/PolynomialCurve.kt b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/PolynomialCurve.kt new file mode 100644 index 000000000..ccc153551 --- /dev/null +++ b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/PolynomialCurve.kt @@ -0,0 +1,10 @@ +package nebulosa.curve.fitting + +import org.apache.commons.math3.analysis.UnivariateFunction + +interface PolynomialCurve : Curve { + + val polynomial: UnivariateFunction + + override fun value(x: Double) = polynomial.value(x) +} diff --git a/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/QuadraticFitting.kt b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/QuadraticFitting.kt new file mode 100644 index 000000000..8d0d30345 --- /dev/null +++ b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/QuadraticFitting.kt @@ -0,0 +1,24 @@ +package nebulosa.curve.fitting + +import org.apache.commons.math3.analysis.UnivariateFunction +import org.apache.commons.math3.analysis.polynomials.PolynomialFunction +import org.apache.commons.math3.fitting.PolynomialCurveFitter + +data object QuadraticFitting : CurveFitting { + + data class Curve( + override val polynomial: UnivariateFunction, + override val minimum: CurvePoint, + override val rSquared: Double, + ) : FittedCurve, PolynomialCurve + + override fun calculate(points: Collection) = with(PolynomialFunction(FITTER.fit(points))) { + val rSquared = RSquared.calculate(points, this) + val minimumX = coefficients[1] / (-2.0 * coefficients[2]) + val minimumY = value(minimumX) + val minimum = CurvePoint(minimumX, minimumY) + Curve(this, minimum, rSquared) + } + + @JvmStatic private val FITTER = PolynomialCurveFitter.create(2) +} diff --git a/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/RSquared.kt b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/RSquared.kt new file mode 100644 index 000000000..cb2d70493 --- /dev/null +++ b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/RSquared.kt @@ -0,0 +1,34 @@ +package nebulosa.curve.fitting + +import org.apache.commons.math3.analysis.UnivariateFunction +import org.apache.commons.math3.stat.descriptive.DescriptiveStatistics +import kotlin.math.pow + +object RSquared { + + @JvmStatic + fun calculate(points: Collection, function: UnivariateFunction): Double { + val descriptiveStatistics = DescriptiveStatistics(points.size) + val predictedValues = DoubleArray(points.size) + var residualSumOfSquares = 0.0 + + for ((i, point) in points.withIndex()) { + val actualValue = point.y + val predictedValue = function.value(point.x) + predictedValues[i] = predictedValue + + val t = (predictedValue - actualValue).pow(2.0) + residualSumOfSquares += t + descriptiveStatistics.addValue(actualValue) + } + + val avgActualValues = descriptiveStatistics.mean + var totalSumOfSquares = 0.0 + + repeat(points.size) { + totalSumOfSquares += (predictedValues[it] - avgActualValues).pow(2.0) + } + + return 1.0 - (residualSumOfSquares / totalSumOfSquares) + } +} diff --git a/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/TrendLine.kt b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/TrendLine.kt new file mode 100644 index 000000000..de735fc17 --- /dev/null +++ b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/TrendLine.kt @@ -0,0 +1,28 @@ +package nebulosa.curve.fitting + +import nebulosa.curve.fitting.Curve.Companion.curvePoints +import org.apache.commons.math3.stat.regression.SimpleRegression + +data class TrendLine(val points: Collection) : LinearCurve { + + constructor(vararg points: Double) : this(points.curvePoints()) + + private val regression = SimpleRegression() + + init { + points.forEach { regression.addData(it.x, it.y) } + } + + override val slope = regression.slope.let { if (it.isNaN()) 0.0 else it } + + override val intercept = regression.intercept.let { if (it.isNaN()) 0.0 else it } + + override val rSquared = regression.rSquare.let { if (it.isNaN()) 0.0 else it } + + override fun value(x: Double) = if (points.isEmpty()) 0.0 else regression.predict(x) + + companion object { + + @JvmStatic val ZERO = TrendLine() + } +} diff --git a/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/TrendLineFitting.kt b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/TrendLineFitting.kt new file mode 100644 index 000000000..205eb3bed --- /dev/null +++ b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/TrendLineFitting.kt @@ -0,0 +1,31 @@ +package nebulosa.curve.fitting + +data object TrendLineFitting : CurveFitting { + + data class Curve( + val left: TrendLine, + val right: TrendLine, + override val minimum: CurvePoint, + ) : FittedCurve { + + val intersection = left.intersect(right) + + override val rSquared = (left.rSquared + right.rSquared) / 2.0 + + override fun value(x: Double) = if (x < minimum.x) left(x) + else if (x > minimum.x) right(x) + else minimum.y + } + + override fun calculate(points: Collection): Curve { + val minimum = points.minBy { it.y } + + val minX = minimum.x + val minY = minimum.y + 0.1 + + val left = TrendLine(points.filter { it.x < minX && it.y > minY }) + val right = TrendLine(points.filter { it.x > minX && it.y > minY }) + + return Curve(left, right, minimum) + } +} diff --git a/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/ZeroUnivariateFunction.kt b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/ZeroUnivariateFunction.kt new file mode 100644 index 000000000..7b3ab106f --- /dev/null +++ b/nebulosa-curve-fitting/src/main/kotlin/nebulosa/curve/fitting/ZeroUnivariateFunction.kt @@ -0,0 +1,8 @@ +package nebulosa.curve.fitting + +import org.apache.commons.math3.analysis.UnivariateFunction + +object ZeroUnivariateFunction : UnivariateFunction { + + override fun value(x: Double) = 0.0 +} diff --git a/nebulosa-curve-fitting/src/test/kotlin/AutoFocusTest.kt b/nebulosa-curve-fitting/src/test/kotlin/AutoFocusTest.kt new file mode 100644 index 000000000..030123a94 --- /dev/null +++ b/nebulosa-curve-fitting/src/test/kotlin/AutoFocusTest.kt @@ -0,0 +1,64 @@ +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.doubles.plusOrMinus +import io.kotest.matchers.ints.shouldBeExactly +import io.kotest.matchers.shouldBe +import nebulosa.curve.fitting.CurvePoint +import nebulosa.curve.fitting.CurvePoint.Companion.midPoint +import nebulosa.curve.fitting.HyperbolicFitting +import nebulosa.curve.fitting.QuadraticFitting +import nebulosa.curve.fitting.TrendLineFitting +import kotlin.math.roundToInt + +class AutoFocusTest : StringSpec() { + + init { + // The best focus is 8000. + + "near:hyperbolic" { + val points = focusPointsNearBestFocus() + val curve = HyperbolicFitting.calculate(points) + curve.minimum.x.roundToInt() shouldBeExactly 8031 + curve.rSquared shouldBe (0.89 plusOrMinus 1e-2) + } + "near:parabolic" { + val points = focusPointsNearBestFocus() + val curve = QuadraticFitting.calculate(points) + curve.minimum.x.roundToInt() shouldBeExactly 8051 + curve.rSquared shouldBe (0.74 plusOrMinus 1e-2) + } + "near:trendline" { + val points = focusPointsNearBestFocus() + val line = TrendLineFitting.calculate(points) + line.minimum.x.roundToInt() shouldBeExactly 8100 + line.rSquared shouldBe (0.94 plusOrMinus 1e-2) + } + "near:hyperbolic + trendline" { + val points = focusPointsNearBestFocus() + val curve = HyperbolicFitting.calculate(points) + val line = TrendLineFitting.calculate(points) + (curve.minimum midPoint line.intersection).x.roundToInt() shouldBeExactly 7952 + } + "near:parabolic + trendline" { + val points = focusPointsNearBestFocus() + val curve = QuadraticFitting.calculate(points) + val line = TrendLineFitting.calculate(points) + (curve.minimum midPoint line.intersection).x.roundToInt() shouldBeExactly 7962 + } + } + + companion object { + + @JvmStatic + private fun focusPointsNearBestFocus() = listOf( + CurvePoint(10100.0, 13.892408928571431), + CurvePoint(9600.0, 12.879208888888888), + CurvePoint(9100.0, 10.640856213017754), + CurvePoint(8600.0, 6.891483673469387), + CurvePoint(8100.0, 2.9738176470588247), + CurvePoint(7600.0, 5.063299489795917), + CurvePoint(7100.0, 9.326303846153845), + CurvePoint(6600.0, 12.428210576923071), + CurvePoint(6100.0, 13.662644615384618), + ) + } +} diff --git a/nebulosa-curve-fitting/src/test/kotlin/HyperbolicFittingTest.kt b/nebulosa-curve-fitting/src/test/kotlin/HyperbolicFittingTest.kt new file mode 100644 index 000000000..22830f606 --- /dev/null +++ b/nebulosa-curve-fitting/src/test/kotlin/HyperbolicFittingTest.kt @@ -0,0 +1,31 @@ +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.doubles.plusOrMinus +import io.kotest.matchers.shouldBe +import nebulosa.curve.fitting.CurveFitting +import nebulosa.curve.fitting.HyperbolicFitting + +class HyperbolicFittingTest : StringSpec(), CurveFitting by HyperbolicFitting { + + init { + "perfect V-curve with only one minimum point" { + val curve = calculate( + 1.0, 18.0, 2.0, 11.0, 3.0, 6.0, 4.0, 3.0, 5.0, 2.0, + 6.0, 3.0, 7.0, 6.0, 8.0, 11.0, 9.0, 18.0, + ) + + curve.minimum.x shouldBe (5.0 plusOrMinus 1e-12) + curve.minimum.y shouldBe (1.2 plusOrMinus 1e-12) + } + "bad data:prevent infinit loop" { + shouldThrow { calculate(1000.0, 18.0, 1100.0, 0.0, 1200.0, 0.0) } + .message shouldBe "Not enough valid data points to fit a curve." + shouldThrow { calculate(1000.0, 18.0, 1000.0, 18.0, 1000.0, 18.0, 1100.0, 0.0, 1200.0, 0.0) } + .message shouldBe "Not enough valid data points to fit a curve." + shouldThrow { calculate(900.0, 18.0, 1000.0, 18.0, 1000.0, 18.0, 1100.0, 0.0, 1200.0, 0.0) } + .message shouldBe "Not enough valid data points to fit a curve." + shouldThrow { calculate(800.0, 18.0, 900.0, 0.0, 1000.0, 0.0, 1000.0, 18.0, 1100.0, 0.0, 1200.0, 0.0) } + .message shouldBe "Not enough valid data points to fit a curve." + } + } +} diff --git a/nebulosa-curve-fitting/src/test/kotlin/QuadraticFittingTest.kt b/nebulosa-curve-fitting/src/test/kotlin/QuadraticFittingTest.kt new file mode 100644 index 000000000..6839b07b6 --- /dev/null +++ b/nebulosa-curve-fitting/src/test/kotlin/QuadraticFittingTest.kt @@ -0,0 +1,25 @@ +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.doubles.plusOrMinus +import io.kotest.matchers.doubles.shouldBeExactly +import io.kotest.matchers.shouldBe +import nebulosa.curve.fitting.CurveFitting +import nebulosa.curve.fitting.QuadraticFitting + +class QuadraticFittingTest : StringSpec(), CurveFitting by QuadraticFitting { + + init { + "perfect V-curve" { + // (x-5)² + 2 + val curve = calculate( + 1.0, 18.0, 2.0, 11.0, 3.0, 6.0, + 4.0, 3.0, 5.0, 2.0, 6.0, 3.0, + 7.0, 6.0, 8.0, 11.0, 9.0, 18.0, + ) + + curve(5.0) shouldBeExactly 2.0 + curve.minimum.x shouldBe (5.0 plusOrMinus 1e-12) + curve.minimum.y shouldBe (2.0 plusOrMinus 1e-12) + curve.rSquared shouldBe (1.0 plusOrMinus 1e-12) + } + } +} diff --git a/nebulosa-curve-fitting/src/test/kotlin/TrendLineFittingTest.kt b/nebulosa-curve-fitting/src/test/kotlin/TrendLineFittingTest.kt new file mode 100644 index 000000000..4cec0f9e0 --- /dev/null +++ b/nebulosa-curve-fitting/src/test/kotlin/TrendLineFittingTest.kt @@ -0,0 +1,32 @@ +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.doubles.plusOrMinus +import io.kotest.matchers.doubles.shouldBeExactly +import io.kotest.matchers.shouldBe +import nebulosa.curve.fitting.CurveFitting +import nebulosa.curve.fitting.TrendLineFitting + +class TrendLineFittingTest : StringSpec(), CurveFitting by TrendLineFitting { + + init { + "perfect V-curve with only one minimum point" { + val curve = calculate( + 1.0, 10.0, 2.0, 8.0, 3.0, 6.0, 4.0, 4.0, // left + 9.0, 10.0, 8.0, 8.0, 7.0, 6.0, 6.0, 4.0, // right + 5.0, 2.0, // tip + ) + + curve.intersection.x shouldBeExactly 5.0 + curve.intersection.y shouldBeExactly 2.0 + } + "perfect V-curve with flat tip with multiple points" { + val curve = calculate( + 1.0, 10.0, 2.0, 8.0, 3.0, 6.0, 4.0, 4.0, // left + 11.0, 10.0, 10.0, 8.0, 9.0, 6.0, 8.0, 4.0, // right + 5.0, 2.1, 6.0, 2.0, 7.0, 2.1, // tip + ) + + curve.intersection.x shouldBe (6.0 plusOrMinus 1e-12) + curve.intersection.y shouldBe (0.0 plusOrMinus 1e-12) + } + } +} diff --git a/nebulosa-curve-fitting/src/test/kotlin/TrendLineTest.kt b/nebulosa-curve-fitting/src/test/kotlin/TrendLineTest.kt new file mode 100644 index 000000000..45a69d859 --- /dev/null +++ b/nebulosa-curve-fitting/src/test/kotlin/TrendLineTest.kt @@ -0,0 +1,33 @@ +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.doubles.shouldBeExactly +import nebulosa.curve.fitting.TrendLine + +class TrendLineTest : StringSpec() { + + init { + "no points" { + val line = TrendLine.ZERO + + line.slope shouldBeExactly 0.0 + line.intercept shouldBeExactly 0.0 + } + "one point" { + val line = TrendLine(5.0, 5.0) + + line.slope shouldBeExactly 0.0 + line.intercept shouldBeExactly 0.0 + } + "two points" { + val line = TrendLine(0.0, 0.0, 1.0, 1.0) + + line.slope shouldBeExactly 1.0 + line.intercept shouldBeExactly 0.0 + } + "multiple points" { + val line = TrendLine(1.0, 10.0, 2.0, 8.0, 3.0, 6.0, 4.0, 4.0) + + line.slope shouldBeExactly -2.0 + line.intercept shouldBeExactly 12.0 + } + } +} diff --git a/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/GuideCalibrator.kt b/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/GuideCalibrator.kt index fe19b05c5..336196527 100644 --- a/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/GuideCalibrator.kt +++ b/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/GuideCalibrator.kt @@ -677,12 +677,12 @@ internal class GuideCalibrator(private val guider: MultiStarGuider) { @JvmStatic private fun mountCoords(camera: Point, x: Angle, y: Angle): Point { - val hyp = camera.distance + val length = camera.length val cameraTheta = camera.angle val yAngleError = ((x - y) + PIOVERTWO).normalized - PI val xAngle = cameraTheta - x val yAngle = cameraTheta - (x + yAngleError) - return Point(hyp * xAngle.cos, hyp * yAngle.sin) + return Point(length * xAngle.cos, length * yAngle.sin) } } } diff --git a/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/GuidePoint.kt b/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/GuidePoint.kt index 54a4e921d..bb9a53f51 100644 --- a/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/GuidePoint.kt +++ b/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/GuidePoint.kt @@ -1,30 +1,8 @@ package nebulosa.guiding.internal -import nebulosa.math.Angle import nebulosa.math.Point2D -import nebulosa.math.rad -import kotlin.math.atan2 -import kotlin.math.hypot interface GuidePoint : Point2D { val valid: Boolean - - fun dX(point: Point2D): Double { - return x - point.x - } - - fun dY(point: Point2D): Double { - return y - point.y - } - - val distance - get() = hypot(x, y) - - val angle - get() = atan2(y, x).rad - - fun angle(point: Point2D): Angle { - return atan2(dY(point), dX(point)).rad - } } diff --git a/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/MultiStarGuider.kt b/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/MultiStarGuider.kt index 6462014ae..8992bcc7e 100644 --- a/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/MultiStarGuider.kt +++ b/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/MultiStarGuider.kt @@ -264,7 +264,7 @@ class MultiStarGuider : InternalGuider { return if (lockPosition(newLockPosition)) { // Update average distance right away so GetCurrentDistance // reflects the increased distance from the dither. - val dist = cameraDelta.distance + val dist = cameraDelta.length val distRA = abs(mountDelta.x) avgDistance += dist avgDistanceLong += dist @@ -276,7 +276,7 @@ class MultiStarGuider : InternalGuider { ditherRecenterDir[0] = if (mountDelta.x < 0.0) 1.0 else -1.0 ditherRecenterDir[1] = if (mountDelta.y < 0.0) 1.0 else -1.0 // Make each step a bit less than the full search region distance to avoid losing the star. - val f = (searchRegion * 0.7) / ditherRecenterRemaining.distance + val f = (searchRegion * 0.7) / ditherRecenterRemaining.length ditherRecenterStep.set(f * ditherRecenterRemaining.x, f * ditherRecenterRemaining.y) } @@ -1107,21 +1107,21 @@ class MultiStarGuider : InternalGuider { private fun transformMountCoordinatesToCameraCoordinates(mount: Point, camera: Point): Boolean { if (!mount.valid) return false - val distance = mount.distance + val length = mount.length var mountTheta = mount.angle if (abs(guideCalibrator.yAngleError) > PIOVERTWO) mountTheta = -mountTheta val xAngle = mountTheta + guideCalibrator.xAngle - camera.set(xAngle.cos * distance, xAngle.sin * distance) + camera.set(xAngle.cos * length, xAngle.sin * length) return true } private fun transformCameraCoordinatesToMountCoordinates(camera: Point, mount: Point): Boolean { if (!camera.valid) return false - val distance = camera.distance + val length = camera.length val cameraTheta = camera.angle val xAngle = cameraTheta - guideCalibrator.xAngle val yAngle = cameraTheta - (guideCalibrator.xAngle + guideCalibrator.yAngleError) - mount.set(xAngle.cos * distance, yAngle.sin * distance) + mount.set(xAngle.cos * length, yAngle.sin * length) return true } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/GPSDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/GPSDevice.kt index 37c82ab5b..e13d4d49f 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/GPSDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/GPSDevice.kt @@ -56,8 +56,6 @@ internal open class GPSDevice( override fun close() = Unit - override fun toString(): String { - return "GPS(hasGPS=$hasGPS, longitude=$longitude, latitude=$latitude," + - " elevation=$elevation, dateTime=$dateTime)" - } + override fun toString() = "GPS(hasGPS=$hasGPS, longitude=$longitude, latitude=$latitude," + + " elevation=$elevation, dateTime=$dateTime)" } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/focusers/INDIFocuser.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/focusers/INDIFocuser.kt index 7a42caa30..82413ddf1 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/focusers/INDIFocuser.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/focusers/INDIFocuser.kt @@ -185,12 +185,10 @@ internal open class INDIFocuser( } } - override fun toString(): String { - return "Focuser(name=$name, moving=$moving, position=$position," + - " canAbsoluteMove=$canAbsoluteMove, canRelativeMove=$canRelativeMove," + - " canAbort=$canAbort, canReverse=$canReverse, reversed=$reversed," + - " canSync=$canSync, hasBacklash=$hasBacklash," + - " maxPosition=$maxPosition, hasThermometer=$hasThermometer," + - " temperature=$temperature)" - } + override fun toString() = "Focuser(name=$name, moving=$moving, position=$position," + + " canAbsoluteMove=$canAbsoluteMove, canRelativeMove=$canRelativeMove," + + " canAbort=$canAbort, canReverse=$canReverse, reversed=$reversed," + + " canSync=$canSync, hasBacklash=$hasBacklash," + + " maxPosition=$maxPosition, hasThermometer=$hasThermometer," + + " temperature=$temperature)" } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/INDIMount.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/INDIMount.kt index 60b94cc64..f2899d9f1 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/INDIMount.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/INDIMount.kt @@ -337,16 +337,14 @@ internal open class INDIMount( } } - override fun toString(): String { - return "Mount(name=$name, connected=$connected, slewing=$slewing, tracking=$tracking," + - " parking=$parking, parked=$parked, canAbort=$canAbort," + - " canSync=$canSync, canPark=$canPark, slewRates=$slewRates," + - " slewRate=$slewRate, mountType=$mountType, trackModes=$trackModes," + - " trackMode=$trackMode, pierSide=$pierSide, guideRateWE=$guideRateWE," + - " guideRateNS=$guideRateNS, rightAscension=$rightAscension," + - " declination=$declination, canPulseGuide=$canPulseGuide," + - " pulseGuiding=$pulseGuiding)" - } + override fun toString() = "Mount(name=$name, connected=$connected, slewing=$slewing, tracking=$tracking," + + " parking=$parking, parked=$parked, canAbort=$canAbort," + + " canSync=$canSync, canPark=$canPark, slewRates=$slewRates," + + " slewRate=$slewRate, mountType=$mountType, trackModes=$trackModes," + + " trackMode=$trackMode, pierSide=$pierSide, guideRateWE=$guideRateWE," + + " guideRateNS=$guideRateNS, rightAscension=$rightAscension," + + " declination=$declination, canPulseGuide=$canPulseGuide," + + " pulseGuiding=$pulseGuiding)" companion object { diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/wheels/INDIFilterWheel.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/wheels/INDIFilterWheel.kt index c5b5354e3..530bea68f 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/wheels/INDIFilterWheel.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/wheels/INDIFilterWheel.kt @@ -85,8 +85,5 @@ internal open class INDIFilterWheel( override fun close() = Unit - override fun toString(): String { - return "FilterWheel(name=$name, slotCount=$count, position=$position," + - " moving=$moving)" - } + override fun toString() = "FilterWheel(name=$name, slotCount=$count, position=$position, moving=$moving)" } diff --git a/nebulosa-math/src/main/kotlin/nebulosa/math/Matrix3D.kt b/nebulosa-math/src/main/kotlin/nebulosa/math/Matrix3D.kt index 0304ba8cc..245881016 100644 --- a/nebulosa-math/src/main/kotlin/nebulosa/math/Matrix3D.kt +++ b/nebulosa-math/src/main/kotlin/nebulosa/math/Matrix3D.kt @@ -169,8 +169,8 @@ open class Matrix3D(@PublishedApi @JvmField internal val matrix: DoubleArray) : override fun clone() = Matrix3D(matrix.copyOf()) fun isEmpty() = a11 == 0.0 && a12 == 0.0 && a13 == 0.0 && - a21 == 0.0 && a22 == 0.0 && a23 == 0.0 && - a31 == 0.0 && a32 == 0.0 && a33 == 0.0 + a21 == 0.0 && a22 == 0.0 && a23 == 0.0 && + a31 == 0.0 && a32 == 0.0 && a33 == 0.0 override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/nebulosa-math/src/main/kotlin/nebulosa/math/Point2D.kt b/nebulosa-math/src/main/kotlin/nebulosa/math/Point2D.kt index ea18cf534..988209142 100644 --- a/nebulosa-math/src/main/kotlin/nebulosa/math/Point2D.kt +++ b/nebulosa-math/src/main/kotlin/nebulosa/math/Point2D.kt @@ -1,5 +1,6 @@ package nebulosa.math +import kotlin.math.atan2 import kotlin.math.hypot interface Point2D { @@ -12,7 +13,27 @@ interface Point2D { operator fun component2() = y - fun distance(other: Point2D): Double { - return hypot(x - other.x, y - other.y) + val length + get() = hypot(x, y) + + val angle + get() = atan2(y, x) + + fun dX(point: Point2D) = x - point.x + + fun dY(point: Point2D) = y - point.y + + fun distance(other: Point2D) = hypot(x - other.x, y - other.y) + + fun angle(other: Point2D): Angle = atan2(other.y - y, other.x - x) + + data class XY(override val x: Double, override val y: Double) : Point2D + + companion object { + + @JvmStatic val ZERO: Point2D = XY(0.0, 0.0) + + @JvmStatic + operator fun invoke(x: Double, y: Double): Point2D = XY(x, y) } } diff --git a/nebulosa-math/src/main/kotlin/nebulosa/math/Point3D.kt b/nebulosa-math/src/main/kotlin/nebulosa/math/Point3D.kt index 996d2a2ee..99d131f5b 100644 --- a/nebulosa-math/src/main/kotlin/nebulosa/math/Point3D.kt +++ b/nebulosa-math/src/main/kotlin/nebulosa/math/Point3D.kt @@ -8,10 +8,39 @@ interface Point3D : Point2D { operator fun component3() = z + override val length + get() = sqrt(x * x + y * y + z * z) + fun distance(other: Point3D): Double { val dx = x - other.x val dy = y - other.y val dz = z - other.z return sqrt(dx * dx + dy * dy + dz * dz) } + + override fun distance(other: Point2D): Double { + val dx = x - other.x + val dy = y - other.y + return sqrt(dx * dx + dy * dy + z * z) + } + + fun angle(other: Point3D): Angle { + val dot = x * other.x + y * other.y + z * other.z + return dot / (length * other.length) + } + + override fun angle(other: Point2D): Angle { + val dot = x * other.x + y * other.y + return dot / (length * other.length) + } + + data class XYZ(override val x: Double, override val y: Double, override val z: Double) : Point3D + + companion object { + + @JvmStatic val ZERO: Point3D = XYZ(0.0, 0.0, 0.0) + + @JvmStatic + operator fun invoke(x: Double, y: Double, z: Double): Point3D = XYZ(x, y, z) + } } diff --git a/nebulosa-math/src/main/kotlin/nebulosa/math/Vector3D.kt b/nebulosa-math/src/main/kotlin/nebulosa/math/Vector3D.kt index b641ed4ed..4b47df852 100644 --- a/nebulosa-math/src/main/kotlin/nebulosa/math/Vector3D.kt +++ b/nebulosa-math/src/main/kotlin/nebulosa/math/Vector3D.kt @@ -3,7 +3,6 @@ package nebulosa.math import kotlin.math.abs import kotlin.math.acos import kotlin.math.atan2 -import kotlin.math.sqrt @Suppress("NOTHING_TO_INLINE") open class Vector3D protected constructor(@PublishedApi @JvmField internal val vector: DoubleArray) : Point3D, Cloneable { @@ -56,9 +55,6 @@ open class Vector3D protected constructor(@PublishedApi @JvmField internal val v vector[0] * other[1] - vector[1] * other[0] ) - inline val length - get() = sqrt(dot(this)) - inline val normalized get() = length.let { if (it == 0.0) this else this / it } diff --git a/nebulosa-nova/src/main/kotlin/nebulosa/nova/astrometry/ELPMPP02.kt b/nebulosa-nova/src/main/kotlin/nebulosa/nova/astrometry/ELPMPP02.kt index 9061cdeca..6487f5867 100644 --- a/nebulosa-nova/src/main/kotlin/nebulosa/nova/astrometry/ELPMPP02.kt +++ b/nebulosa-nova/src/main/kotlin/nebulosa/nova/astrometry/ELPMPP02.kt @@ -6,7 +6,6 @@ import nebulosa.io.bufferedResource import nebulosa.math.Matrix3D import nebulosa.math.Vector3D import nebulosa.math.normalized -import nebulosa.math.pmod import nebulosa.time.InstantOfTime import kotlin.math.atan2 import kotlin.math.cos diff --git a/nebulosa-nova/src/main/kotlin/nebulosa/nova/astrometry/FixedStar.kt b/nebulosa-nova/src/main/kotlin/nebulosa/nova/astrometry/FixedStar.kt index e644d25bc..15ba0e91a 100644 --- a/nebulosa-nova/src/main/kotlin/nebulosa/nova/astrometry/FixedStar.kt +++ b/nebulosa-nova/src/main/kotlin/nebulosa/nova/astrometry/FixedStar.kt @@ -50,8 +50,7 @@ data class FixedStar( // unit vector "u1", divided by the speed of light. val lightTime = u1.dot(observer.position) / SPEED_OF_LIGHT_AU_DAY val position = (positionAndVelocity.position + positionAndVelocity.velocity * - (observer.time.tdb.whole - epoch.tdb.whole + lightTime + observer.time.tdb.fraction - epoch.tdb.fraction) - - observer.position) + (observer.time.tdb.whole - epoch.tdb.whole + lightTime + observer.time.tdb.fraction - epoch.tdb.fraction) - observer.position) return PositionAndVelocity(position, observer.velocity - positionAndVelocity.velocity) } diff --git a/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/GeographicPosition.kt b/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/GeographicPosition.kt index 5d6a1e3c6..9696ecfe2 100644 --- a/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/GeographicPosition.kt +++ b/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/GeographicPosition.kt @@ -106,7 +106,7 @@ class GeographicPosition( } override fun toString() = "GeographicPosition(longitude=${longitude.toDegrees}, " + - "latitude=${latitude.toDegrees}, elevation=${elevation.toMeters}, model=$model)" + "latitude=${latitude.toDegrees}, elevation=${elevation.toMeters}, model=$model)" companion object { diff --git a/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/ICRF.kt b/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/ICRF.kt index 1cbc928f5..b0f456ef9 100644 --- a/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/ICRF.kt +++ b/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/ICRF.kt @@ -214,8 +214,8 @@ open class ICRF protected constructor( val horizontalRotation by lazy { require(target is GeographicPosition || target is PlanetograhicPosition) { "to compute an altazimuth position, you must observe from " + - "a specific Earth location or from a position on another body loaded from a set " + - "of planetary constants" + "a specific Earth location or from a position on another body loaded from a set " + + "of planetary constants" } (target as Frame).rotationAt(time) @@ -255,8 +255,8 @@ open class ICRF protected constructor( ?: (position.center as? Frame)?.rotationAt(position.time) ?: throw IllegalArgumentException( "to compute an altazimuth position, you must observe from " + - "a specific Earth location or from a position on another body loaded from a set " + - "of planetary constants" + "a specific Earth location or from a position on another body loaded from a set " + + "of planetary constants" ) val coordinate = SphericalCoordinate.of(r * position.position) diff --git a/nebulosa-watney/src/main/kotlin/nebulosa/watney/plate/solving/WatneyPlateSolver.kt b/nebulosa-watney/src/main/kotlin/nebulosa/watney/plate/solving/WatneyPlateSolver.kt index 249aec548..ece585232 100644 --- a/nebulosa-watney/src/main/kotlin/nebulosa/watney/plate/solving/WatneyPlateSolver.kt +++ b/nebulosa-watney/src/main/kotlin/nebulosa/watney/plate/solving/WatneyPlateSolver.kt @@ -286,7 +286,7 @@ data class WatneyPlateSolver( @JvmStatic private fun isValidSolution(solution: ComputedPlateSolution?): Boolean { return solution != null && solution.centerRA.isFinite() && solution.centerDEC.isFinite() - && solution.orientation.isFinite() && solution.plateConstants.isValid + && solution.orientation.isFinite() && solution.plateConstants.isValid } @JvmStatic diff --git a/nebulosa-watney/src/main/kotlin/nebulosa/watney/star/detection/Star.kt b/nebulosa-watney/src/main/kotlin/nebulosa/watney/star/detection/Star.kt index 57cfd3a13..ee650cec0 100644 --- a/nebulosa-watney/src/main/kotlin/nebulosa/watney/star/detection/Star.kt +++ b/nebulosa-watney/src/main/kotlin/nebulosa/watney/star/detection/Star.kt @@ -3,7 +3,8 @@ package nebulosa.watney.star.detection import nebulosa.star.detection.ImageStar data class Star( - override val x: Double = 0.0, override val y: Double = 0.0, + override val x: Double = 0.0, + override val y: Double = 0.0, val size: Double = 0.0, override var hfd: Double = 0.0, override var snr: Double = 0.0, diff --git a/settings.gradle.kts b/settings.gradle.kts index daf2ac638..0e03d366f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,6 +30,7 @@ dependencyResolutionManagement { library("apache-lang3", "org.apache.commons:commons-lang3:3.14.0") library("apache-codec", "commons-codec:commons-codec:1.17.0") library("apache-collections", "org.apache.commons:commons-collections4:4.4") + library("apache-math", "org.apache.commons:commons-math3:3.6.1") library("apache-numbers-complex", "org.apache.commons:commons-numbers-complex:1.1") library("oshi", "com.github.oshi:oshi-core:6.6.1") library("jna", "net.java.dev.jna:jna:5.14.0") @@ -54,6 +55,7 @@ include(":nebulosa-astrometrynet") include(":nebulosa-astrometrynet-jna") include(":nebulosa-common") include(":nebulosa-constants") +include(":nebulosa-curve-fitting") include(":nebulosa-erfa") include(":nebulosa-fits") include(":nebulosa-guiding")