The Celx Programming Language
for Astronomical Simulation with Celestia
by Mark Meretzky

Joseph Wright's Orrery

Cover illustration or frontispiece: A Philosopher Lecturing on the Orrery (1766) by Joseph Wright at the Derby Museums.

About This Document

This document is an HTML5 file with CANVAS tags for the diagrams. JavaScript is used for the diagrams, tables of contents, cross references, and index. If the Chrome browser fails to render a diagram, display and hide the JavaScript Console and the diagram will appear. If Firefox says “Unresponsive script”, press the Continue button.

Each diagram is pixels wide. Each Celx code sample is at most 80 characters wide, with eight-character tabs. One excerpt from celestia.cfg in opengl is 94 characters wide; a line in Celestia:newposition is 84 characters wide. The above Wright painting is a 800 × 600 jpg. The Celestia screenshot in screenShot is a 1024 × 790 png. © 2013 Mark Meretzky. All rights reserved.

Links to the Lua documentation go to Lua version 5.2, even though the current Celestia uses Lua 5.1. Links to the Celx source code are provided by the gateway at http://oit2.scps.nyu.edu/cgi-bin/~meretzkm/celx. Links to astronomical and mathematical topics go to Wikipedia. The HTML5 has been validated by http://validator.w3.org/ and (with the CANVAS tags stripped out) by tidy.

Introduction

What we’d like to do is give every freshman biochemistry student access to a $5 million recombinant DNA wet lab. We cannot do that. But what we can do is simulate that.
Steve JobsJobs, Steve, October 1987

Celestia Celestia is a computer application that displays a real-time, three-dimensional simulation of the Universe with photorealistic OpenGL graphics. When a full moon hangs over Manhattan, Celestia will render the Sun, the Moon, and New York in their actual positions and orientations. The universe can be displayed at any time and at any speed, forward, backward, or frozen. The user’s viewpoint can be fixed on the surface of a planet, mounted on a spacecraft, or piloted through the empty reaches of space. The viewpoint’s motion can be linear or curved, accelerated or constant, and accompanied by yaw, pitch, and roll.

Celestia thus combines the functionality of a camera, telescope, compass, GPS, orrery, planetarium, and starship, not to mention one of those little plastic disks with a grommet that shows you tonight’s constellations. The historically minded user can also simulate a sundial, sextant, astrolabe, armillary sphere, or a plain old noon mark.

Most people explore the Celestia universe by clicking and dragging a mouse, or, nowadays, by tapping and swiping a finger. It’s easy to go close enough to Jupiter to see the satellites, or to Saturn to see the rings. But for precise, reproducible results, far beyond those accessible to the casual user, Celestia can also be driven by a program or script.

A script can move the viewpoint to an exact position in space and time, with a precise velocity, acceleration, and orientation. We can go to Mongolia to watch a solar eclipse, or hire a jet to follow the path of totality. We can fly to the Moon with Ranger 7Ranger 7 (spacecraft)—or with George MélièsMéliès, George—and up from the Moon with Stanley KubrickKubrick, Stanley. We can observe the phases of Venus as seen from the Earth, or the phases of the Earth as seen from Mars. We can cruise alongside the Earth, keeping it stationary while the planets loop around it, or cruise alongside the CassiniCassini (spacecraft) spacecraft as HuygensHuygens (spacecraft) separates from it. And we can hold the camera steady enough to see the parallax of the star 61 Cygni.

A script can calculate an exact point in space—or in time. When is the sunset at a given date, at a given latitude and longitude on Earth? Or on the Moon, or on Mars? When are Easter and Passover, Eid al-Adha and Chinese New Year? When will Saturn’s rings be edge-on as seen from Earth? Or the orbital plane of Charon around Pluto? When will Mercury be at its next greatest elongation, and where will it appear against the background of the constellations? What will be its right ascension and declination, altitude and azimuth, and distance from the Earth? And does its perihelion really precess around the Sun as EinsteinEinstein, Albert predicted?

A script can overlay the Celestia window with OpenGL graphics. We can draw the lines on the face of a sundial, plot the equation of time, or construct the figure eight of the analemma—for any planet. We can draw on the surface of the celestial sphere, tracing the retrograde motion of Mars or the epicycles of the Ptolemaic solar system. We can draw on the surface of a planet, marking the path of an eclipse or an occultation. We can draw on the surface of the window, showing the orbital resonances of Jupiter’s moons with a corkscrew diagram. And we can draw in three-dimensional space, highlighting the Lagrangian points and equipotentials of a two-body system or building a wireframe model of Kepler’s Law of Equal Areas.

A script can search Celestia’s databases of stars and galaxies. We can plot the stars along the Main Sequence of the Hertzsprung-Russell diagram. We can see if the globular clusters are symmetrically distributed around the center of the Milky Way. We can check if the galaxies are really thicker along the supergalactic plane or thinner along the Zone of Avoidance. And we can list all the stars whose names contain the Arabic word for “tail”.

But all of these wonders are going to require programming. Celestia understands two programming languages, Cel Cel (programming language) and CelxCelx (programming language). The former is hardly a language at all, having no variables or control structure—loops, “if” statements, and functions. Celx, on the other hand, inherits these useful features from the language LuaLua (programming language), of which it is a superset. (Incidentally, the name Lua means “Moon” in Portuguese.) Celx also has classes of objects specifically for mathematics and astronomy, allowing us to harness the full power of Celestia’s simulation of the Universe with only a modest amount of coding.

The present book is a tutorial, cookbook, and reference for writing programs in the language Celx to drive Celestia. It covers the five groups of tasks that a program can do.

First, we can harvest information in numerical or graphical form from Celestia’s simulation of the universe. For example, we can ask when and where an eclipse will be total, how fast a planet will orbit the Sun, or from what angle a meteoroid stream will approach the Earth. Second, we can use this information to place the viewpoint into the simulation at a precisely computed time, place, velocity, and orientation. We can go to that eclipse and travel with it as it sweeps across the face of the Earth, or aim the camera at the radiant point of the meteor shower. Third, we can change the way the simulation is presented to the user. The window can be one large view or a splitscreen; the view can be telescopic or wide field; and the timescale can be fast forward or slow motion. The program can also overlay the window with graphics keyed to the simulated universe in the window. Fourth, we can interact with the real world. The program can read and write files, and react to keystrokes, mouse moves, or to the mere passage of real time. Finally, we can attach a Celx program to a spacecraft or planet in the simulation to pilot it on a new course. The resulting trajectory is called a “scripted orbit” or “scripted rotation”.

This book places Celx scripting squarely in the Unix tradition of making a program coöperate with its neighbors. A script is first of all a text file that can be generated by another program. The script can be launched from a command line in a shellscript or other script. It can communicate with other programs via pipes or other standard i/o, and can return an exit status to its parent. Our intent is to help users make Celx and Celestia part of a larger system involving, for example, Google Maps, a GPS or a telescope mounting, or a self-updating planetarium show. We also introduce a Standard Library for CelxCelx Standard Library, to take the rough edges off of Celx programming.

This book is addressed to the following people.

How much math do you need for this book? If you remember your high school trigonometry—sine and cosine, arcsine and arccosine—you can read more than 90% of it. See trigonometryFun for the tutorial. How much programming? If you know about variables and assignment statements, loops and “if” statements, you can read more than 90% of it. See idiosyncrasies for the tutorial. How much astronomy and navigation? If you know the difference between latitude and longitude, horizon and zenith, you can read 100% of it. See sphericalAstronomy for the tutorial.

Reasoning in three dimensions takes practice. A desktop celestial sphere will aid the reader in visualizing the equinoxes, solstices, and the other cardinal directions in the many frames of reference offered by Celestia. If no sphere is available, get an orange and draw on it with a magic marker or stick pins into it. A conventional globe of the Earth will be helpful in seeing where the axes and other vectors pierce the terrestrial surface. The author always carries in his pocket a cheap little painted tin globe with a pencil sharpener in its base.

Celestia is free and runs on Macintosh macOS, Microsoft Windows, and Linux. It is open source and can be ported to any platform that has the languages C and C++ and the graphics library OpenGL. An enthusiastic user community has extended Celestia with additional planets and spacecraft (real and imagined), and surface imagery with new layers of information. But libraries of Celx functions and programs are coming more slowly, which is one of the reasons for this book.

The Celx viewpoint or camera is called the observerobserver (viewpoint), and is referred to herein as “he” only for convenience. I am grateful to the creators of Lua and Celestia, to Harald Schmidt Schmidt, Harald (documenter) for his pioneering Celx documentation, and to Marc Taylor Taylor, Marc (planetarium operator) for introducing me to the Celestia universe.

Mark Meretzky
New York University
mark.meretzky@nyu.edu
http://oit2.scps.nyu.edu/~meretzkm/

Versions and Conventions

This section lists the rules we obey, voluntarily or otherwise, when writing programs in Celx. You can skip it on first reading.

Language Versions

This book describes version 1.6.1 of Celestia, which implements a Celx language that is a superset of version 5.1.4 of LuaLua (programming language). Since the current version of Lua is already 5.2.2, we will be forced to write obsolete or deprecated code in a few places. See lua52 for the list of things to be changed in our Celx code when Celestia is upgraded to Lua 5.2.

For your version numbers of Lua, Celestia, and the Celx Standard Library, see the Lua variable _VERSION and the standard library variables std.celestiaVersion and std.libraryVersion.

The Celestia:geturlCelestia:geturl method of Celestia version 1.6.1 returns a cel:cel: URL URLURL (Uniform Resource Locator) containing a version number of 3. All earlier versions of Celestia generated a URL with no version number at all.

Source Directories

We will occasionally glance at the source code of Celestia and Lua. The Celestia source resides in a tree of directories whose root is named version. We will cite the files starting at this root. The Lua source is rooted at luaversion. The data files read by Celestia at runtime are in the Celx directory (celxDirectory) and its subdirectories: data, extras-standard, fonts, and textures.

The Celestia source code is mostly in the language C++C++ (programming language). The .m files are in Objective-CObjective-C (programming language) for Macintosh, and the .mm files are a mixture of Objective-C and C++. The Lua source code is in the language CC (programming language).

Naming Conventionsnaming conventions

Our convention in this book is to give lowercase names to Celx variables and functions. This includes variables that hold a constant value, in agreement with the Lua constant math.pi.

A name can be formed by concatenating two or more words. Celx already has three conflicting conventions for pasting them together.

  1. Concatenation in all lowercase, as in the Lua functions tostring and tonumber, the Celx methods Celestia:getobserver and Celestia:newposition, and the field renderoverlay in the Lua hook table in luaHookFunctions. (A “method” is a certain type of function; see objectsAndClasses.)
  2. Concatenation with underscores, as in the Celx variable KM_PER_MICROLY (lightyear) and the Celx functions celestia_cleanup_callback and celestia_keyboard_callback (callback).
  3. Concatenation with embedded capitals, as in the field atmosphereHeight in the Celx info table (infoTable) and the field initialOrientation in the Observer:goto table.

Our convention in this book is to use concatenation in all lowercase for the names of methods; see objectsAndClasses. This agrees with the existing Celx convention for methods. We use concatenation with embedded capitals for the names of other functions and variables.

We place “longitude” before “latitude” in the names we invent, to agree with the Celx function Observer:gotolonglat. Examples include Celestia:newpositionlonglat and Vector:getlonglat.

What is the opposite of “end”? Celx uses both “start” and “begin”. For example, the Observer:goto table contains the fields startInterpolation and endInterpolation (animateRotation), while the table returned by a scripted orbit function contains the fields beginDate and endDate (Geostationary). Our convention is to use “start” because it has fewer syllables.

Some Celx methods return a Lua table, such as Celestia:tdbtoutc and Celestia:fromjulianday. Others return a list of values, such as Celestia:getscreendimension and Phase:timespan. Most of the methods we invent return tables; examples include Position:getlonglat, Vector:getlonglat, etc. Exceptions are Position:getxyz and Vector:getxyz, whose purpose is to return an argument list for the Lua function string.format, and std.tourl, which return the argument list for the method Celestia:newposition. Another exception is Object:family, which is required to return a list by the semantics of the Lua generic for statement. Finally, Rotation:getforwardup proved easier to use when it returned a list.

Celx Code Format

Every complete Celx program is printed in a solid box, adorned with a link to the source code online. A Celx code fragment is printed in a dashed box. Output is in a double box.

Note that “complete” is a relative term. If the program has the following require statement, --This is a Celx code fragment. require("std") the program will need the file std.luastd.lua (file) in order to run. This file contains the Celx Standard Library (standard) and is printed in full in stdCelx. Some programs will also need a Lua hook file (luaHookFunctions) or files containing scripted orbitsscripted orbit and/or rotationsscripted rotation (scriptedOrbit).

Our Celx code is written in the plain vanilla ASCII character set, with a maximum line length of 80 characters and 8-character tab stops. But these restrictions are not required by Celestia. We obey them only to make the code readable on every platform.

Despite being written in ASCII, many of our programs output non-ASCII characters. Examples include the symbols for degree, minute, and second (1° 2′ 3″). These “special” characters are encoded in the UTF-8UTF-8 (character encoding) format.

Play with Celestia

Let’s play with Celestia interactively before we start writing programs.

Download Celestia

Download Celestia from

http://www.shatters.net/celestia/

The Macintosh macOS and Microsoft Windows versions install unproblematically. During the Windows installation, under “Select Additional Tasks”, check the checkbox for “Associate .cel and .celx scripts”. On Linux, download the Celestia package for your distribution. Do not download the “Linux (x86) Version 1.4.1” from the downloads page on the Celestia website—this version cannot run Celx programs.

At a much later stage, you might want to compile your own Celestia from its source code. See compileMacintosh for Macintosh macOS, compileWindows for Microsoft Windows, compileLinux for a Linux version of Unix, and solaris for the Solaris version.

Two Cel Programs that Come with Celestia

On startup, Celestia automatically runs an eight-second program named start.celstart.cel (Cel program). It whisks us safely away from the Sun and leaves us above the sunlit side of the Earth with the message “Welcome to Celestia!”. To immerse yourself in the Celestia universe, you can then maximize the Celestia window.

Another program is demo.celdemo.cel (Cel program), a four-minute tour of the Celestia universe. Pull down the Celestia menu on Mac, or the Help menu on Microsoft Windows, and select Run Demo. When done, it leaves us back at the Earth with the message “Demo completed.”

These programs are not hardwired into Celestia. They are specified in the configuration file celestia.cfgcelestia.cfg (configuration file):

#This is an excerpt from celestia.cfg. #Comments in this file start with a pound sign. InitScript "start.cel" DemoScript "demo.cel"

To find the “Celx directory”Celx directory that holds the configuration file and the programs, see celxDirectory.

Keystroke and Mouse Commands

This section demonstrates some of Celestia’s interactive commandskeystroke commands. It previews the features that will be invoked non-interactively by the Celx programs later in the book. For the complete list of interactive commands, pull down Celestia’s Help menu and select Celestia Help on Mac, Controls on Microsoft Windows.

  1. The Celestia point of view is called the observerobserver (viewpoint). When Celestia is launched, start.cel places him six Earth radii from the center of the Earth, facing the sunlit side of the planet. To rotate him around the Earth, you can then drag the mouse across the planet. On Mac, you right-drag or option-drag; on Microsoft Windows, you right-drag. You can also rotate him by holding down the shift key and pressing the four arrow keys. To roll the observer, press the left and right arrows without shifting. Visit Europe, Australia, and the North and South Poles.
  2. A celestial object may be selectedselect (an object), causing its name to be displayed in the upper left corner of the window. The selected object is initially the Earth. To select a star, click on the star. To select the Earth again, click on the Earth. For the time being, let the Earth remain selected.
  3. If the Earth somehow becomes unselected and goes out of the picture, there’s another way to get it selected again. Press return on Mac, Enter on Microsoft Windows, to get the prompt Target name:. Then type Earth and press return or Enter. The Earth is now selected. Press c to center the selected object in your field of view, and g to go there. Press HomeHome key and EndEnd key to go towards and away from the selected object. If your keyboard has no Home and End, you can drag up and down to get the same effect: command drag on Mac, left drag on Microsoft Windows.
  4. Toggle the Earth’s features:

    i
    control-a
    control-l
    o
    clouds
    the rest of the atmosphere
    nightside lightslights (nightside) (lowercase L)
    the planets’ orbitsorbits (shown as lines) around the Sun (lowercase O)

    Note that the orbit of the selected planet is red. Go to the night side of the Earth and turn the ambient light level up and down with the curly braces } and {. Then go back to the default level of 0.10.
  5. The astronomical name of the Sun is “Sol”Sol. Drag around the Earth to the subsolar pointsubsolar point—the point on the Earth where the Sun is directly overhead. Can you get the Phase anglephase angle in the upper left corner to go down to exactly zero? When you get as close as possible to zero, press ** (rear-view mirror command) (asterisk, the rear-view mirror command) to face the Sun. Press another * to face back to the Earth.
  6. Was it hard to locate the subsolar point? Let’s mark it with a bright yellow vector (arrow). Right-click on the Earth and select Reference Vectors → Show Sun DirectionSun direction from the context menu.
  7. Drag around the Earth to the antipode of the subsolar point, where the phase angle is 180°. Get as close to it as you can. The Sun will be hidden by the Earth.
  8. Was it hard to locate the antipode? If so, display the yellow sun direction vector. Then change the Earth into an OpenGL wireframewireframe mode (OpenGL) by pressing control-w. You will now be able to see the vector through the Earth. Once you have located the antipode, turn the wireframe mode off with another control-w.
  9. Press End and Home to withdraw from the Earth until it just barely covers the Sun. We have just traveled to the vertex (tip) of the Earth’s umbraumbra (of Earth). We can withdraw further to make an annular eclipseannular eclipse.
  10. Get a better view of your eclipse with Celestia’s telescopic vision. First note the default FOV (field of view)field of view in the lower right corner. Press comma, (zoom in command) and period. (zoom out command) to zoom in and out. Then set the field of view back to the original value.
  11. Go back to the Earth (select it and press g) just so we know where we are. Then look at the rest of the Universe and toggle the displays:

    /
    =
    control-b
    ;
     
    constellation constellations stick figures
    constellation names
    constellation boundaries
    “equatorial” grid equatorial grid showing right ascension and declination
    (celestial longitude and latitude)

    Make the stars brighter and dimmer with the square brackets ] and [. Then go back to the default magnitude magnitude, apparent of 7.0.
  12. Turn on the equatorial grid and the constellation stick figures, and drag around the Earth until you are directly above the Earth’s South Pole. Observe that the Earth is now directly in front of the north celestial pole north celestial pole in Ursa MinorUrsa Minor, blocking our view of the North StarNorth Star, PolarisPolaris (star). Press End to get farther from the Earth so it blocks less of the sky.
  13. Display the red, green, and blue “body axes” that come out of the Earth. Right-click on the Earth and select Reference Vectors → Show Body Axes. We will see in bodyfixedFrame that these are the axes of the Earth’s bodyfixed frame of referencebodyfixed frame of reference, but they are labelled with names that disagree with those used in a Celx program. The blue axis at the North Pole is is referred to in Celx as the bodyfixed Y axis, and the green one in the Indian Ocean is referred to in Celx as the bodyfixed negative Z axis.
  14. Verify that the red bodyfixed X axis emerges from the Earth at latitude 0°, longitude in the South Atlantic Ocean. Right-click on the Earth and select Reference Vectors → Show Planetographic Gridplanetographic grid. The green axis (the bodyfixed negative Z axis) emerges at latitude 0°, longitude 90° East in the Indian Ocean. Then turn off the body axes and planetographic grid.
  15. Display the red, green, and blue “frame axes” that come out of the Earth, slightly duller than the body axes. Right-click and select Reference Vectors → Show Frame Axes. We will see in eclipticFrame that these are the axes of the Earth’s ecliptic frame of referenceecliptic frame of reference.
  16. The start of spring in the northern hemisphere of the Earth is called the vernal equinoxvernal equinox, usually occurring around March 21. We also use the term to mean the direction from the Earth to the Sun at this point in time. This direction points towards the constellation PiscesPisces; in other words, the Sun blocks our view of Pisces on the first day of spring. Verify that the X axis of the Earth’s ecliptic frame (dull red) points towards the equinox. Turn on the constellation names, stick figures, and boundaries, and also the equatorial grid and wireframe mode.
  17. The summer solstice summer solstice in the borderlands of Taurus Taurus and Gemini Gemini is the direction from the Earth to the Sun on the first day of summer in the Earth’s northern hemisphere. Once again, the labels that we see disagree with the names used in a Celx program. Verify that the dull green axis (which Celx would refer to as the negative Z axis of the Earth’s ecliptic frame) points towards the solstice. Verify that the dull blue axis (which Celx would refer to as the Y axis of the Earth’s ecliptic frame) points towards Polaris.
  18. Press lowercase o to display the orbits of the planets around the Sun. If the Earth is selected, its orbit will be red. To see which way is the Earth traveling along its orbit, select Reference Vectors → Show Velocity Vectorvelocity vector.
  19. To fast-forward the Earth’s rotation, press l (lowercase L) three times. The time scale (the rate at which time passes) is displayed temporarily in the lower left corner, permanently in the upper right corner. To return to normal speed, press lowercase k three times. To toggle the direction of time, press j. Watch the people turning on their lights as they move into the night side of the Earth.
  20. Verify that the Earth’s (bright) bodyfixed axes rotate with the Earth, while the Earth’s (dull) ecliptic axes remain pointing at the vernal equinox and summer solstice. Then set the time scale back to the original rate of 1 (“Real time”).
  21. Turn on the constellations with / (diagonal slash) and the equatorial grid with ; (semicolon). To pan the view left and right, up and down, drag on the sky. Pan over to the center of the Milky Way in SagittariusSagittarius at Right Ascension 17h 46m, Declination –29°. Make the galaxy brighter galaxy light gain and dimmer with the parentheses ) and (. Then go back to the default light gain of 0%.
  22. Select HubbleHubble Space Telescope, the telescope in orbit around the Earth. Then press c c (center command) to center it and g g (go command) to go there. Drag around it and look at it from various angles. Does it have body axes and ecliptic axes? Select and go to the Moon, the planet Saturn, the star Betelgeuse, and the galaxy M 31 (a.k.a. the Andromeda Galaxy; one space after the uppercase M). To get back to the Earth from points outside our Solar System, you will have to select Sol/slash (in name of celestial object)Earth with a diagonal slash. Or simply press h h (home command) (home) to select the Sun, and g to go to it.
  23. To toggle the Celestia consoleconsole, press ~~ (toggle console comand) (tilde). Scroll it up and down with the arrow keys. How many stars are in the binary database?
  24. Quit Celestiaquit Celestia. On Mac, Celestia → Quit Celestia; on Microsoft Windows, File → Exit.

Sine and Cosine

τὸ δὲ τῶν ὀρνέων φῦλον μετερρυθμίζετο, ἀντὶ τριχῶν πτερὰ φύον, ἐκ τῶν ἀκάκων ἀνδρῶν, κούφων δέ, καὶ μετεωρολογικῶν μέν, ἡγουμένων δὲ δι΄ ὄψεως τὰς περὶ τούτων ἀποδείξεις βεβαιοτάτας εἶναι δι΄ εὐήθειαν.
Πλάτων, Τίμαος (360 BC)
We remind readers of Plato’s warning (Timaeus,Timaeus (Plato) 91d): the innocent and light-minded, who believe that astronomy can be studied by looking at the heavens without knowledge of mathematics, will return in the next life as birds.
Linda S. SparkeSparke, Linda S. and John S. GallagherGallagher, John S.,
Galaxies in the Universe: An IntroductionGalaxies in the Universe: An Introduction (Sparke and Gallagher) (2000)

On startup, the observer is deposited by start.cel at a position six Earth radii from the center of the Earth, or five radii from the surface. The trig trigonometry function sinesine (trig function), as we all remember, is “opposite over hypotenuse”. Conversely, arcsinarcsine (trig function) 1 6 is the angle whose sine is 1/6. This is the angle subtended (spanned) by half of the Earth from the observer’s point of view.
The whole Earth subtends an angle twice as wide: 2 arcsin 1 6
The angle returned by the Lua arcsine function is in radiansradian. To convert it to degrees, we multiply by 180/π. The Earth therefore subtends an angle of 180 π 2 arcsin 1 6 degrees from the observer’s point of view, approximately 19.1881364537209°. (We write our numbers with 15 significant digits; see doubleArithmetic.) Each degree consists of 60 minutes of arc, and each minute consists of 60 seconds. We can therefore write the angle as 19° 11′ 17.2912333953224″ Does this agree with the Earth’s “Apparent diameter” apparent diameter in the upper left corner of the window?

Given this apparent diameter of 19.1881364537209° ≈ 19° 11′ 17.2912333953224″ we can reconstruct the observer’s distance in Earth radii from the center of the Earth. Half of the apparent diameter is 19.1881364537209 2 degrees. The sine of this angle is “opposite over hypotenuse”, i.e., the Earth’s radius over the observer’s distance from the center of the Earth. The argument of the Lua sine function is in radians, so we convert the angle to radians by multiplying by π/180. The sine is sin ( π 180 19.1881364537209 2) ≈ .166666666666667 ≈ 1 6 Therefore the observer is 6 radii from the center, or 5 radii from the surface. Does the latter figure agree with the Earth’s distance and radius in the upper left corner of the window?

The Earth’s limblimb is the apparent edge of the Earth as seen by an observer. It is the circle where the surface of the Earth meets the background of space. (If we were much closer to the Earth, we would call it the horizonhorizon.) The observer is 6 Earth radii from the center of the Earth. How far is he from any point on the Earth’s limb? The middle diagram above suggest that the distance should be slightly less than 6 radii.

This is a job for the trig function tangenttangent (trig function), also known as “opposite over adjacent”.

tan ( π 180 19.1881364537209 2 ) ≈ 0.169030850945703 ≈ 1 5.91607978309962

The observer is therefore approximately 5.9 Earth radii from the limb.

The field of view field of view is the angle subtended by the height of picture in the Celestia window. It does not include the title bar and the horizontal scroll bar. The angle is displayed in degrees as the “FOV” in the lower right corner.

Look at the Earth and select it to display its apparent diameter. Then drag on the lower right corner of the window and shorten it until it is just barely tall enough to contain the Earth. Has the field of view become equal to the Earth’s apparent diameter?

Select the Moon and press g to go there. You are now five Moon radii from the center of the Moon, a distance set by the function getPreferredDistance in the file version/src/celengine/observer.cpp. At this distance, the Moon should subtend an angle of 180 π 2 arcsin 1 5 degrees, approximately 23° 4′ 26.1050362715114″. Does it?

A First Celx Program

Let’s write a “Hello, World!” program“Hello, World!” program in Celx, save it on the disk, and run it.

The Celx DirectoryCelx directory

Before we write the program, we have to decide what directory to put it in. We will choose the directory that makes it easiest to run the program by typing an operating system command line. This directory is called the Celx directory and it already holds the configuration file celestia.cfgcelestia.cfg (configuration file) and the programs start.cel and demo.cel that we ran in play. It will also hold the Celx Standard Library file std.luastd.lua (file) (standard), the Lua hook file luahook.celx (luaHookFunctions), and the files for scripted orbits and rotations (scriptedOrbit).

Although the Celx directory is in a different location on each platform, we can always find it by searching for the file start.cel.

Macintosh macOS

To get to the Celx directory on a Mac, start with the application Celestia.app. A Mac application is actually a “package”package (Macintosh) of directories and files. (“Directory” and “folder” are synonymous on Mac.) The root directory of this package is named Celestia.app. The Celx directory is a sub-sub-sub directory named CelestiaResourcesCelestiaResources (Macintosh directory). To get there,

  1. Double-click on the icon for the Celestia volume you downloaded.
  2. Control-click on Celestia.app and select Show Package Contents.
  3. Drill down through Contents and Resources to CelestiaResources.

When you get to the Celx directory, you should see the files celestia.cfg, start.cel, and demo.cel.

To go to the Celx directory in the Terminal application, give the following commands.

cd /Volumes/Celestia/Celestia.app/Contents/Resources/CelestiaResources pwd ls -l celestia.cfg start.cel demo.cel (lowercase LS minus L)

The entire Celestia volume is read-only. To store a program in the Celx directory, you must give yourself read and write privileges for that volume. Control-click on the icon for the Celestia volume, select Get Info, and open Sharing & Permissions.

If you have no permission to change the permissions, a workaround is to create a read/write copy of Celestia.app. Double-click on the Celestia volume, control-click on Celestia.app, and select Copy "Celestia.app". Then control-click on a new location (your desktop, or the Applications folder in the Macintosh HD) and select Paste Item to create a new Celestia.app. From now on, our Celx directory will be the CelestiaResources sub-sub-sub directory of the new Celestia.app. For example, if we pasted the new Celestia.app onto our desktop, we can get to the Celx directory with the following Terminal command. cd /Users/Yourname/Desktop/Celestia.app/Contents/Resources/CelestiaResources

Microsoft Windows

The Celestia Setup Wizard tells us the name of the Celx directory during installation. It defaults to a directory such as one of the following. C:\Users\Myname\AppData\Local\Celestia
C:\Program Files (x86)\Celestia
C:\Program Files\Celestia
Go to the directory and look for the files celestia.cfg, start.cel, and demo.cel. If you don’t see the filename extensions (.cfg and .cel) in the directory, go to
Start → Control Panel → Appearance and Personalization → Folder Options → View → Advanced settings
and uncheck “Hide extensions for known file types”. Press Apply and OK.

Another way to get to the Celx directory is to open the Command Prompt window by running cmd.exe. Then give the following commands. cd C:\Users\Myname\AppData\Local\Celestia (or whichever directory it is) dir celestia.cfg start.cel demo.cel

Linux and Other Versions of Unix

The Celx directory defaults to /usr/local/share/celestia. A different default can be set by passing the command line options --prefix=PREFIX and --datarootdir=DIR to the configureconfigure (shellscript) shellscript that is run before Celestia is compiled. For help with these options, download the Celestia source code and say

cd version
./configure --help | more

If you don’t know where the Celx directory is, find it by searching for the file start.cel with the following command. Discard any error messages by directing them into the garbage pail /dev/null. There is no space between the 2 and the >. find / -type f -name start.cel -print 2> /dev/null

Now that you know where your Celx directory is, go there and read start.cel and demo.cel.

  1. On Mac, control-click on start.cel, select Open With, and choose an editor such as TextEdit.appTextEdit.app (Macintosh text editor).
  2. On Microsoft Windows, right-click on start.cel, select Open With…, and choose an editor such as WordPadWordPad (Windows text editor) or NotepadNotepad (Windows text editor).
  3. On Linux and other Unixes, including Mac Terminal, open start.cel with a text editor such as vivi (Unix text editor), vimvim (Unix text editor), emacsemacs (Unix text editor), or picopico (unix text editor). I use vi; my children use pico.

Observe that CelCel (programming language) is an impoverished language. It has no variables or objects, only literal numbers and strings. It also has no control structure—no loops, “if” statements, or functions. That’s why we’re going to program in Celx.

The Celx Program Itself

“It is not enough to discharge a projectile and take no further notice of it. We must follow it throughout its course, up to the moment when it reaches its target.”

“What?” exclaimed the general and the major, a bit taken aback by this idea.

“Absolutely,” replied BarbicaneBarbicane, Impey with self-assurance. “Absolutely. Otherwise our experiment would produce no result.”
Jules VerneVerne, Jules, From the Earth to the MoonFrom the Earth to the Moon (Verne) (1865)

Our first Celx program will have to perform some kind of output, to prove to us that it actually ran. Otherwise our experiment would produce no result.

Go to the Celx directory (celxDirectory) and launch any text editor capable of creating a plain text file. It can be the text editor with which you examined start.cel in the above exercise. Write your program in a text file named hello.celx (or hello.clx, if you find it important to have a filename extension of exactly three characters).

We will draw a solid box around each program. Commentscomment start with a double dash, implying that Celx does not have the decrement operator “--” used in the language C. A double-dash comment extends to the end of the line. A double-dash comment with double square brackets can extend onto additional lines. Don’t forget the closing brackets.

Do not confuse the lowercase letter o with the digit 0, or the lowercase letter l with the digit 1. For example, the filename extension .celx has a lowercase l. Do not confuse the dash - with the underscore _, or the (parentheses) with the [square brackets] and {curly braces}.

--[[
This file is named hello.celx.
Keep the Sun reassuringly in view,
but position the observer away from its glare.
]]

observer = celestia:getobserver()
position = celestia:newposition(0, 0, 1)	--x, y, z coordinates
observer:setposition(position)

celestia:print("Hello, world!", 10)		--for 10 seconds
wait()			--Give Celestia a chance to update the window.

--[[
Celestia keeps running after the Celx program has finished.
The variables observer and position, and the objects to which they refer,
continue to exist and can be mentioned by subsequent Celx programs.
The text "Hello, world!" remains in the window even after the program
has finished.
]]

Save the file as plain text, since rich text would make Celestia complain about an invisible “curly brace in line 1”. In Mac TextEditTextEdit.app (Macintosh text editor), pull down the Format menu and select Make Plain Text. In Microsoft WordPadWordPad (Windows text editor), select

Save as → Plain text document
Save as type: TextDocument

When the program is run in launch, the output of Celestia:print (celestiaPrint) will be white on black. But we will just give it a double border to save ink. Hello, world!

Objects and Classes, Arguments and Return Values

Let’s examine the objects and methods of the program in programItself. An objectobject (in programming language) is something that does useful jobs for us. There are many classes of objectsclass (of object), including the twelve classes that Celx has added to Lua (listed in additions). The name of each of these classes starts with an uppercase letter.

One class of object is named CelestiaCelestia (class). This class is unusual in that there is always exactly one object of this class (at least until we get to luaHookFunctions). We don’t have to create or destroy this object: it always exists. The object of class Celestia is named celestiacelestia (object), with a lowercase c.

The other two objects, observer and position, are created by the program. They belong to classes ObserverObserver (class) and PositionPosition (class) respectively. But the name of an object does not necessarily have to be the name of its class in lowercase. Just choose a name that reminds you of what the object does.

The jobs that an object can do are called the methodsmethod of the object. An object of class Celestia has methods named getobserver, newposition, and print. The methods of the objects of each class are listed in additions. To tell an object to call (perform) one of its methods, we write the name of the object on the left and the name of its method on the right, joined by a colon. We also write a pair of parentheses after the name of the method. Thus,

celestia:getobserver()

A method can access whatever data might be in the object to which it belongs. For example, the getobserver method of the celestia object can access the data in the celestia object. In addition, some methods must be fed additional data such as the three numbers we passed to newposition. These values are called the argumentsargument (of method) of the method and are written inside the parentheses. Even if there are no arguments, the parentheses are still required. Multiple arguments are separated by commas.

A method can produce an object or other value as the result of its work. This value is called the return valuereturn value (of method) of the method. For example, the getobserver method of the object of class Celestia (known henceforth as Celestia:getobserver) gets an object of class Observer and returns it to us. An Observer object represents a point of view in the simulated universe. It has a position, orientation, and velocity. The observer’s initial position is at the barycenterbarycenter (of Solar System) of the Solar System, always in or near the Sun.

We also receive a return value from Celestia:newposition, in this case an object of class Position. The new position was chosen to keep the Sun reassuringly in view—at a respectful distance. The three coördinates of this position are explained in universalFrame.

Some methods produce no return value. Observer:setposition moves the observer to a new position, but returns no value. Celestia:print displays text in the window, but returns no value. See celestiaPrint for the arguments of Celestia:print.

When Celestia is launched without specifying any Celx program, the program start.celstart.cel (Cel program) (programs) automatically whisks the observer to a safe distance from the barycenter. But when we launch Celestia by launching a Celx program, start.cel is not executed and the Celx program must get the observer away from the barycenter. This is an important responsibility. If we remain too close to the Sun, the glare will be blinding. Even worse, if we are inside the Sun, the Sun will be invisible and the user will be disoriented.

A method that does not belong to any object is called a functionfunction. For example, the function waitwait gives Celestia an opportunity to update its window. See wait.

Launch the Celx Program

We can launch our program hello.celx in three ways. The third way must be used if the Celx program produces “standard output” (standardOutput).

  1. Launch Celestia, which automatically executes start.cel. Then pull down Celestia’s File menu, select Run Script…, and choose hello.celx. This will execute hello.celx without executing start.cel a second time.
  2. Double-click on the operating system icon for hello.celx. This will launch Celestia without executing start.cel. Celestia will then execute hello.celx.
  3. Type an operating system command line. This will launch Celestia without executing start.cel. Celestia will then execute hello.celx.

Details about launching on the three major platforms are in the following subsections.

There is no way to pass command line arguments to a Celx program.

A Celx program inherits many values from the previous Celx program(s) run by the same instance of Celestia. These values include the observer object, the renderflags (renderFlags), the nonlocal variables referring to objects and functions (functions), the permissions to access the local file system and operating system via the Lua variables io and os (osExit), and even the six-digit screenshotCount (screenShot). The simplest way to discard this baggage and guarantee a clean start is to quit Celestia and launch it again. For now, don’t worry about it.

A running Celx program can be pausedpause and unpaused with the space bar, and cancelledcancel (terminated) with the escape key. But the escape key does not cancel an event handler function (eventHandler) or a Lua hook function (luaHookFunctions).

The program we’re about to launch contains Celx code that will execute once and then come to an end. Later, we will see other ways of submitting Celx code to Celestia: as a Lua hook (opengl), a scripted orbit or rotation (scriptedOrbit), or a file that is required (standard) by any of the above. A Celx program can also run a Celx program in another file by calling the method Celestia:runscript, or a Celx program in a string by calling the Lua function loadstring.

One piece of trivia. On each platform, the name of the Celx program must be specified with at least one slashslash (in filename) if the program calls the methods Celestia:loadtexture or Celestia:runscript. For example, the name would have to be specified as ./hello.celx or /pathname/of/hello.celx instead of hello.celx.

Macintosh macOS

1. Launch Celestia, pull down the File menu, and select Run Script…. Observe that the resulting menu does not let us drill down into Celestia.app, making it impossible to reach the Celx directory CelestiaResources and run hello.celx. The solution is to move hello.celx into the scripts subdirectory of the Celx directory. We can then get to it by selecting File → Scripts.

2. We can also run hello.celx by double-clicking on the its icon in the Macintosh Finder. If the double click does not launch Celestia, make sure you have chosen Celestia as the application for opening .celx files. Control-click on hello.celx, select Open With, choose Celestia, and check “Always Open With”.

3. We can also run hello.celx by typing a command line in the Terminal application. The command line will launch the executable file Celestia, which will then execute hello.celx.

To make it easy to launch the executable Celestia, take the name of the directory that holds Celestia and append it to the PATH environment variable. You can accomplish this automatically every time you open a Terminal window by putting the following command into the .bash_profile.bash_profile (shellscript) file in your home directory.

#Excerpt from the ~~ (home directory)/.bash_profile file. #Assumes you have copied your Celestia.app onto your Desktop. export PATH=$PATH:~/Desktop/Celestia.app/Contents/MacOS

Now close and reopen the Terminal window and type the following commands into it.

echo $PATH which Celestia /Users/myname/Desktop/Celestia.app/Contents/MacOS/Celestia

If no directory name is specified for hello.celx, Celestia will look for it in the Celx directory. In the jargon of operating systems, a pathname passed to Celestia is “relative to” the Celx directory.

Celestia hello.celx (Directory name is not specified.) Celestia ~/Desktop/hello.celx (full pathname) Celestia scripts/hello.celx (relative pathname, without a leading slash)

The Celx program can be run again by typing command-r into Celestia. There is no need to make the Celx program “executable” with chmod, but see pound, which offers an easier way to run the program.

Microsoft Windows

1. Launch Celestia and pull down the File menu. Select Open Script… and choose your program hello.celx.

2. We can also run hello.celx by double-clicking on its operating system icon. If the double click does not launch Celestia, make sure you have chosen Celestia as the application for opening .celx files. Right-click on hello.celx, select Open WithChoose Program…, choose Celestia, and check “Always use the selected program to open this kind of file”.

3. We can also run hello.celx by opening the Command Prompt window and type one of the following commands. (Launch cmd.exe to open the window.) If no directory name is specified for hello.celx, Celestia will look for it in the Celx directory. In the jargon of operating systems, a pathname passed to Celestia is “relative to” the Celx directory.

hello.celx (Directory name is not specified.) C:\Users\Myname\Desktop\hello.celx (Directory name is specified.) "C:\Users\My Name\Desktop\hello.celx" (Needs quotes if name has space.)

The above commands should launch Celestia because we said “Associate .cel and .celx scripts” when we installed Celestia. If Celestia doesn’t launch, we will have to tell the computer that a .celx program should be executed by Celestia.

On Windows 7,
Start → Control Panel → Programs → Default Programs → Associate a file type or protocol with a program
The extension .celx should be listed. If it isn’t, make it open with Celestia.

On Windows XP,
Start → My Computer → Tools → Folder Options… → File Types
If CELX is not listed under Extensions, right-click on hello.celx and select Open. Press “Select the program from a list”, press OK, and browse to Celestia. Check “Always use the selected program to open this kind of file”.

On Windows Vista,
Start → Control Panel → Programs → Default Programs → Make a file type always open in a specific program
The extension .celx should be listed. If it isn’t, make it open with Celestia.

Linux and Other Versions of Unix

Do not install the outmoded Linux version 1.4.1 in the Celestia download page: it rejects a .celx file as an “Invalid filetype”. To run Celx on Linux, get a fresh Celestia from your Linux distribution or go to compileLinux and compile Celestia from the source code.

If no directory name is specified for hello.celx, Celestia will look for it in the Celx directory. In the jargon of operating systems, we say that a pathname passed to Celestia is “relative to” the Celx directory. The -f in front of hello.celx is necessary only if Celestia was compiled with the --with-glutGLUT (OpenGL Utility Toolkit) option of the configureconfigure (shellscript) shellscript. It is not necessary if Celestia was compiled with the --with-gnome, --with-gtk, or --with-kde options.

celestia -f hello.celx (Directory name is not specified.) celestia -f ~/hello.celx (Directory name is specified.)

One exception: if Celestia was compiled --with-kdeKDE (K Desktop Environment), the pathname of the Celx program is relative to the user’s current directory, not the Celx directory. The location of the Celx program affects the arguments of the Celx methods Celestia:loadtexture and Celestia:runscript.

To launch Celestia or any Celx program on my Solaris Unix server, I first have to connect to the server by launching an X WindowX Window server on a Mac or Microsoft Windows. On a Mac, I launch X11.app and log into the Unix server with sshssh -Y. On Microsoft Windows, I launch XmingXming and log into the Unix server with PuTTYPuTTY set to “Enable X11 Forwarding”. Look under PuTTY Configuration → Category → Connection → SSH → X11 or Tunnels.

See pound to make the .celx file executable.

The Idiosyncrasies of Celx

Every language has its own features. Some of the distinguishing marks of Celx were inherited from Lua; others were introduced by Celestia.

Objects vs. Variables

Since the celestia object is heavily used in Celx, we could have given it a shorter name. The following code is a Celx fragment, not a complete program, so we’ll put a dashed box around it. c = celestia observer = c:getobserver() observer = c:newposition(0, 0, 1) The object of class Celestia is unaware that it now has an extra name. In fact, it never knew that it had the name celestia. c and celestia are actually the names of little containers called variablesvariable, completely external to the object. Each variable contains the memory address of an object, by means of which the computer can find its way to the object. The memory address is therefore called a referencereference to object to the object, and a variable that contains a reference is said to refer to the object. In our example, the variables c and celestia refer to the same object. For convenience, we will continue to speak of “the celestia object”, but what we really mean is “the object referred to by the variable named celestia”.

A variable is not an object and does not contain an object. Our assignment statement c = celestia copies the content of the variable celestia into the variable named observer. But this content is merely a reference to an object. The statement does not copy the object itself. Remember, there is only one object of type Celestia.

Our second assignment statement observer = c:getobserver() copies the return value of getobserver into the variable named observer. Again, this return value is merely a reference to an object of type Observer. The object itself is not copied: there is only one observer object in this program.

Functions and Local Variables

A group of Celx statements may be packaged as a functionfunction, shown below. The statements are called the body of the function, and are surrounded by the keywords function and end. To make the keywords more conspicuous, the body of the function indented.

Our convention is to name the first function mainmain (function), but this is not required by the language. In fact, the function is not actually named main, just as the celestia object is not actually named celestia. main is merely a variable that refers to the function, just as celestia is a variable that refers to the object. See the comment alongside the word main.

Packaging the program as a set of functions will let us control how long our variables and objects stay alive. Recall that our original program left behind the variables observer and position and the objects to which they refer. This is wasteful—and a potential breach of security because a subsequent program could manipulate those objects.

To ensure that each variable, object, and function is destroyed as soon as possible, we will mark every variable with the keyword locallocal variable. A local variable created inside the body of a function will exist only as long as the function is being executed; an example is the following variable observer. A local variable created outside the body of a function will exist only as long as the program is being executed; an example is the following variable main. As soon as an object or function has no more variables referring to it, the object or function is no longer accessible to any program and can be automatically incinerated by the Lua garbage collector.

--[[
Create and call a function named main.
]]

local function main()	--this line can also be written local main = function()

	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:getobserver()
	local position = celestia:newposition(0, 0, 1)
	observer:setposition(position)

	celestia:print("Hello, world!", 10)
	wait()

	--[[
	The variables observer and position will be destroyed when we reach the
	following word "end".  Since there are no other variables that refer to
	the Observer and Position objects, these objects may now be destroyed at
	any time by the Lua garbage collector.
	]]
end

--[[
The following statement calls (i.e., executes) the main function.
This will execute the five statements that constitute the body of the function.
]]

main()

--[[
The variable main will be destroyed when we reach the end of the program.  Since
there are no other variables referring to the function, the function may now be
destroyed by the garbage collector.  As usual, Celestia keeps running after the
Celx program is over.
]]

We prefer to create each variable inside of a function. But there are two occasions when we cannot do this. First, each time a local statement is executed, it creates a new variable. If the statement is in a function that is called many times, it will therefore create many variables. If we want a single variable, the variable will have to be created outside of every function. The most common example will be a variable used by each call to a tick handler function (tickHandler).

Second, a variable created in a function can be mentioned only within that function. If the variable needs to be mentioned outside of a function, or in more than one function, the variable will have to be created outside of every function. The most common example will be the variable main, which we will always mention on the last line of a program, outside of every function.

Celx requires two special groups of functions to be nonlocal: the callback functions in callback and the scripted orbits and rotations in scriptedOrbit. All of our other functions will be local.

Methods and Non-methods

Celx functionsfunction fall into two groups, the methods and the non-methods. A methodmethod belongs to an object and can read and write the data in the object. For example, the following call to setposition sets the position of the observer object to which the method belongs. The object and its method are joined with a colon. observer:setposition(position)

A note on nomenclature. To call this method in a Celx program, we write the name of a variable in front of the colon. But to talk about the method itself, quite apart from any particular object of class Observer, we write the name of a class in front of the colon. The name of a Celx class starts with an uppercase letter. Thus we have just called the Observer:setposition method of the object to which the variable observer refers.

A non-method function is called with a dot and cannot read or write the object in front of it. For example, the following functions random and sqrt get no data from the object math. This object is just a convenient last name or family name for grouping these functions together. We will see in arrayOfStrings and standard that math and std are actually “tables”. local x = math.random() local y = math.sqrt(x) local c = std.utf8(0x0041) --the string "A" os.exit(0) --Terminate the Celx program and then terminate Celestia.

Some non-method functions are orphans, belonging to no object at all. They are written without any dot or colon. local s = tostring(10) local t = type(s) print("to the standard output") wait() --a non-method function that Celestia added to Lua main() --a non-method function that we created ourselves For the three non-method functions that Celx added to Lua, see additions.

Chain the Method Calls Together

We often call a method of an object returned by a previous method call. For example, local observer = celestia:getobserver() observer:setposition(position) These two statements could be telescoped into one statement. celestia:getobserver():setposition(position)

A longer sequence such as local earth = celestia:find("Sol/Earth") local position = earth:getposition() local x = position:getx() --position contains x, y, z coordinates. can be telescoped to local x = celestia:find("Sol/Earth"):getposition():getx() By the way, this x is our first variable that does not contain a reference to an object or to a function. It contains a plain old number.

Telescope the following chain into a single statement that creates the same variable length. Do not bother to create the variables observer, orientation, or axis. local observer = celestia:getobserver() --observer is an Observer. local orientation = observer:getorientation() --Which way is he facing? local axis = orientation:imag() --axis is a Vector. local length = axis:length() --length is a number.

Many methods return an object. Some methods return a function. See the eye-popping method Object:phases in phases.

Control Structurecontrol structure

By default, a program executes its statements one at a time from top to bottom. The control structure of a program causes it to execute its statements in a different order. Celx inherits its plain vanilla control structure statements from the language Lua. For examples, see the following sections and their exercises.

functions function/end create a user-defined function
callback return return a value from a user-defined function
tickHandler return return without a value from a user-defined function
   
osExit if/then/end
callback if/then/else/end
callback if/then/elseif/end
   
callback while/do/end test at top of loop
wait for/do/end test at top of loop (numeric for loop)
arrayOfStrings for/do/end test at top of loop (generic for loop, with an iterator)
stefanBoltzamnn repeat/until test at bottom of loop
inputFile break break out of loop
Recursion recursion

For a loop that counts by twos, see the oblate spheroid example in kilometer. For a loop that counts backwards, see the definition of the method Object:family in the file std.lua in stdCelx. For an infinite loop, see the while true do in parallax. Celx has no switch or continue statements. To terminate the Celx program without terminating Celestia, call the Lua functions error or assert (assert). To terminate the Celx program and terminate Celestia, call os.exitos.exit (osExit).

The waitwait Function

The Celx function wait gives Celestia a chance to update the picture in the window. Until we call wait, the picture will not change and the text will not be printed. Even the simulation time will remain frozen (settime). During the call to wait, the picture can move and the simulation time can advance. In fact, it is only during a wait that these things can happen. Another thing that can happen only during a wait is a call to the input function celestia_keyboard_callback (callback); see the while loop in callback.

As soon as a Celx program is over, Celestia automatically regains control and does any overdue rendering. Thus the wait we saw in programItself and functions was unnecessary since those programs took only a fraction of a second to run. But we will write the wait anyway, because it might become necessary if the code were modified. In a long program, for example, wait must be called at least once every five seconds of real time. A program that tries to run too long without calling wait will be terminated with the message Error: Timeout: script hasn't returned control to celestia (forgot to call wait()?) (Even if the program ends in this ignominious manner, the function celestia_cleanup_callback will still be called [callback]). The five-second limit could easily be reached by a program looping through Celestia’s database of a hundred thousand stars. For this reason, the limit can be temporarily extended by Celestia:settimeslice.

wait takes an optional argument giving the number of real-time seconds it will take to return. This can be used to give a long-running special effect such as Observer:goto time to complete its execution, or to give the user time to read a message.

local duration = 5 --seconds of real time --[[ The argument of this goto is a table created with curly braces. The table contains fields named duration and to. ]] local params = { duration = duration, to = celestia:newposition(0, 0, 2) --pull farther back from Sun } observer:goto(params) wait(duration) duration = 3 celestia:print("You have arrived.", duration) wait(duration)

Warning. The Celx function wait is implemented as a call to the Lua function coroutinecoroutine.yield. A call to wait will therefore never return if it is inside a callback function (callback), an event handler (eventHandler), a Lua hook (luaHookFunctions), the position method of a scripted orbit (scriptedOrbit), the orientation method of scripted rotation (scriptedRotation), or a Lua “coroutine”.

Insert the following empty for loop into hello.celx in functions immediately before the wait. The 10^^ (exponentiation operator)9 means 109 = 1,000,000,000.

--Count by ones from 0 to 2,000,000,000 inclusive. --This takes a long time to do absolutely nothing. for i = 0, 2000000000 do --or 2*10^9 or 2e9 end

Does the program crash after 5 seconds? Now insert the following statement immediately before the for loop.

celestia:settimeslice(100) --up to 100 seconds of real time

Does the program still crash?

Quit Celestia with os.exitos.exit

Celestia normally continues to run after a Celx program has finished. But the program can also terminate Celestia; see standardOutput for an example involving “standard output”.

The Lua function os.exit terminates the Celx program and then terminates Celestia. The function celestia_cleanup_callback in callback will not be called. The argument of os.exit is the exit status number that Celestia will return to the operating system. In most operating systems, zero means success and any nonzero number means failure.

To get permissionaccess policy to mention the variable osos (and the variable ioio in standardOutput and outputFile) we must edit the configuration file celestia.cfgcelestia.cfg (configuration file) and change the parameter ScriptSystemAccessPolicyScriptSystemAccessPolicy (parameter in celestia.cfg) to "allow" or "ask", and relaunch Celestia. The program must also call the method Celestia:requestsystemaccess, even if the access policy is "allow". And if the access policy is "ask", the program must also call wait. No argument is necessary for the wait.

--[[
Demonstrate os.exit, if possible.
]]

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:getobserver()
	local position = celestia:newposition(0, 0, 1)
	observer:setposition(position)

	--Get permission to mention os.  Will ask a question if
	--ScriptSystemAccessPolicy is "ask" in celestia.cfg.
	celestia:requestsystemaccess()
	wait()

	local duration = 5

	if os ~=~= (not equals operator) nil then	--The operator ~= means "is not equal to".
		--Arrive here if the access policy was "allow",
		--or if the access policy was "ask" and the answer was "yes".

		celestia:print("The Celx program will cause Celestia to exit.",
			duration)
		wait(duration)
		os.exit(0)	--0 for success
	end

	--Arrive here if the access policy was "deny",
	--or if the access policy was "ask" and the answer was "no".

	celestia:print("The Celx program can't cause Celestia to exit.",
		duration)
	wait(duration)
end

main()

If the access policy is "ask", Celestia:requestsystemaccess will ask the following question. WARNING: This script requests permission to read/write files and execute external programs. Allowing this can be dangerous. Do you trust the script and want to allow this? y = yes, ESC = cancel script, any other key = no Say yes. If you don’t, the Celx program will end but Celestia will keep running.

To see the exit status returned by Celestia to a Unix operating system, open a shell window. For example, on a Mac, open the Terminal window. In this window, launch Celestia and the Celx program with the following command. Follow it with an echo to display the exit status. Celestia hello.celx echo $? (most Unix shells, including Mac Terminal and Linux) echo $status (Unix C shell)

To see the exit status in on Microsoft Windows, launch cmd.exe to open the Command Prompt window. In this window, launch the Celx program with the following command start /wait command. Follow it with an echo to display the exit status. help start start /wait hello.celx echo %errorlevel%

Two Callback Functionscallback functions

[Bill TindallTindall, Bill wrote:] “… I just feel that the crew should not be diddling with the computer keyboard during powered descent [of the Apollo Lunar ModuleApollo Lunar Module] unless it is absolutely necessary. They will never hit the wrong button, of course, but if they do, the results can be rather lousy.” The next day we started a review of every crew computer keystroke and its effect throughout the descent phase.
Gene KranzKranz, Gene, Failure is Not an Option (2000)

If the program has a nonlocal function with the special name celestia_keyboard_callbackcelestia_keyboard_callback (function), it will be called automatically whenever a character key is pressed. “Characters” include letters, digits, punctuation marks, the blank and tab, delete (backspace), and return. Characters do not include the control, option, and command combinations, the Home and End keys, the four arrow keys, and Escape. For these special keys, write the keydown Lua hook in luaHookFunctions. The characters must be typed into the Celestia window, not the command window from which Celestia was launched.

celestia_keyboard_callback must be enabled by the method Celestia:requestkeyboard. Once enabled, celestia_keyboard_callback will be called even if the program is in the middle of a wait (wait). In fact, that’s the only time it can be called. See the following while loop.

The argument received by celestia_keyboard_callback is the key that was pressed. The following assertions (assert) are primarily for documentation: I don’t expect them to fail. The callback returns true to indicate that the character has been completely handled by the callback and that no further processing is necessary. This means that the character will not be executed as a Celestia keyboard command (play). For example, d will not run the demo and ; (semicolon) will not toggle the equatorial grid. Returning false will allow the character to be executed as a keyboard command. A missing return statement is equivalent to returning true.

The following program accepts one line of keyboard input spelling out a decimal number, possibly including a negative sign, decimal point, and exponent. For example, the line -123.456e-2 represents the number –123.456 × 10–2 = –1.23456. The main function and the callback function communicate via the variable n. They display the input line, the cursor character, and the error message (if any) as the input line is being accumulated.

celestia_keyboard_callback should concentrate on accumulating the characters of input and recognizing special characters such as backspace and return. The time-consuming astronomical calculations should be triggered by a flag and performed outside the callback function. There should be no need for the callback function to call wait. In fact, a wait in a callback function will never return.

The operator ==== (equals operator) performs comparison for equality (no space between the equal signs). The operator .... (concatenation operator) performs string concatenation (no space between the dots). The characters \r\r (carriage return character) and \n\n (newline character) are the carriage returncarriage return (character) and newlinenewline (character). Multiple lines have to be printed as a single string, by means of a single call to Celestia:print, because each call to this method erases the previous text.

--[[
Let the user input a number from the keyboard.  Store it in n and print it.
]]

--variables used by celestia_keyboard_callback:
local n = nil	--Remains nil until we receive valid number and return key.
local line = ""	--Accumulate the line of input that the user types.
local errorMessage = ""

function celestia_keyboard_callback(c)	--can't be local
	assert(type(c) == "string")

	if c == "\r" then   --return key on Mac, Enter key on Microsoft Windows
		n = tonumber(line)	--Try to convert string to number.
		if n == nil then
			errorMessage = "\nThat wasn't a number.  Try again."
		end
	else
		errorMessage = ""	--Give user the benefit of the doubt.
		if c ~= "\b" then   --backspace (Mac delete, Windows Backspace)
			line = line .. c	--Append the char to the line.
		elseif line:lenstring.len() > 0 then	--If the line has a last char,
			line = line:substring.sub(1, -2)	--remove the last char.
		end
	end

	return true	--Do nothing else with this character.
end

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:getobserver()
	local position = celestia:newposition(0, 0, 1)
	observer:setposition(position)

	local cursor = "_"
	celestia:requestkeyboard(true)	--Enable celestia_keyboard_callback.

	while n == nil do
		celestia:print("Type a number and press Return.\n"
			.. line .. cursor .. errorMessage)

		wait()	--Let Celestia call celestia_keyboard_callback.
	end

	--Arrive here after user has typed a valid number and pressed return.
	assert(type(n) == "number")
	celestia:requestkeyboard(false)	--Disable celestia_keyboard_callback.
	local duration = 5
	celestia:print("The number was\n" .. n, duration)
	wait(duration)
end

main()

A number can hold up to 15 significant digits (doubleArithmetic), but only at most 14 of them will print in the default format (numericFormatting). To get all 15, see the string.format in stringFormat.

Type a number and press Return. 123456789012345_ The number was 1.2345678901235e+014

Add a nonlocal function named celestia_cleanup_callbackcelestia_cleanup_callback (function) to your program. Verify that it is called automatically after the program ends, even if the ending is ignominious: a call to error, an assertion failure (assert), or death by not calling wait (wait). Verify that the cleanup callback will not be called if the user terminates the program with the escape key, or if the program calls os.exit. It will also not be called as long as there is an event handler (eventHandler). function celestia_cleanup_callback() celestia:print("Goodbye.", 5) --Do not call wait in a callback function. --If you want to prevent the next Celx program --from inheriting this callback function, celestia_cleanup_callback = nil end

Four Event Handlerevent handler Functions

A Celx program can detect four types of eventsevent, represented by the strings "key", "mousedown", "mouseup", and "tick". Four functions, called event handlers, can be called automatically when the events occur. The handlers are enabled by the method Celestia:registereventhandler.

An event handler will go on being called after the last line in the program has executed. The handler will be called even if the user has cancelled the program with the escape key, or if the program has died by not calling wait (wait). To disable a handler, see the exercise in tickHandler. The error messages from a handler are displayed in the console (console).

Tick Handlertick handler

The simplest event is a tick of the Celestia simulation. The simulation “ticks” about 60 times per second of real time when the main event loop of the graphics package is idle. The tick consists of a call to the C++ member function CelestiaCore::tick in version/src/celestia/celestiacore.cpp.

The handler for this event receives one argument, which is a table containing a field named dt giving the number of real-time seconds since the last tick. dt will be about 1/60, except for the first time this function is called. See arrayOfStrings for tables and tableOfFields for fields. The assertion (assert) in the handler is primarily for documentation. The handler prints the current simulation time in UTC, marching in sync with the time display in the upper right corner of the window. See time for UTC vs. TDB.

The number of percent signs in the first argument of string.formatformat must be equal to the number of subsequent arguments. (The first argument is composed of two concatenated strings and contains a total of seven percent signs.) The format %d prints a number as a decimal integer. The format %02d prints it as a decimal integer with at least two digits. If the number has only one digit, a leading zero will be supplied. The last four arguments of Celestia:print put the string up in the upper right corner of the window; see celestiaPrint.

The most common use of a tick handler is to update the display text, or the observer’s position or orientation. A handler may also be used to print OpenGL graphics (opengl) or to trigger an action when a certain time has been reached. For an example in real time, see the exercise below; for simulation time, see settime. The tick handler in radiantPoint measures the speed of a celestial object by recording the object’s position at two consecutive ticks, and dividing by the interval of simulation time between the ticks.

--[[
Call the tickHandler function every time the Celestia clock ticks.
]]

local function tickHandler(t)
	--t.dt is the number of real-time seconds since the previous tick.
	assert(type(t) == "table" and type(t.dt) == "number")

	--Get the current simulation time (not the current real time).
	local utc = celestia:tdbtoutc(celestia:gettime())

	local s = string.format(
		"%d/%d/%d %02d:%02d:%02d UTC\n"
		.. "seconds since last tick: %.15g",
		utc.month, utc.day, utc.year,
		utc.hour, utc.minute, math.floor(utc.seconds),
		t.dt)

	--Don't risk division by zero.
	if t.dt ~= 0 then
		--Append another line to s.
		s = s .. string.format("\nTicks per second: %.15g", 1 / t.dt)
	end

	celestia:print(s, 1, 1, 1, -28, -3)
end

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:getobserver()
	local position = celestia:newposition(0, 0, 1)
	observer:setposition(position)

	celestia:registereventhandler("tick", tickHandler)
	wait()
end

main()
12/31/2013 08:12:08 UTC Seconds since last tick: 0.016762971878052 Ticks per second: 59.655293063477

Stop calling the tickHandler after the program has been running for 10 seconds. Insert the following code after the assert.

if celestia:getscripttime() > 10 then celestia:registereventhandler("tick", nil) --unregister it return end

Pulse the constellation stick figures in red every two seconds. seconds is the number of real-time seconds since the start of the program. cycles is the number of two-second cycles. x starts at 0 and increases by 2π during each cycle. The sine starts at 0 and goes up, down, and back to 0 during each cycle, contributing to the amount of red in the color.

--At the top of the program, outside the tickHandler. --variable used by tickHandler: local cyclesPerSecond = .5 --Hertz. Each cycle takes 1/cyclesPerSecond seconds. local function tickHandler() --Ignore the tickHandler's argument. local seconds = celestia:getscripttime() --real time since start of prog local cycles = seconds * cyclesPerSecond local x = 2 * math.pi * cycles celestia:setconstellationcolor(.5 + math.sin(x) / 2, 0, 0) --rgb end --In the main function, make sure the constellations are on. celestia:show("constellations") celestia:showlabel("constellations")

Key Handlerkey handler

A handler can also be registered for the "key" event. It is similar to the celestia_keyboard_callback in callback, but can be called when the program is not executing a wait. It can even be called even after the Celx program has terminated.

Unlike celestia_keyboard_callback, the handler will be called for both control and non-control keys. A control key will be received as a three-character string. For example, the Macintosh delete key is received as "C-h" because the ASCII code of a backspace is 8, and h is the eighth letter of the alphabet. Similarly, a return is received as "C-m" because the ASCII code of a carriage return is (decimal) 13. The four arrow keys are not received by this handler; see luaHookFunctions to catch them. The keystrokes must be typed into the Celestia window, not the command window from which Celestia was launched.

As in celestia_keyboard_callback, a return value of true prevents the character received by the handler from being executed as a Celestia keystroke command. A missing return statement in a key handler is equivalent to returning false.

Let’s write a handler to intercept a typical Celestia keystroke command. The argument of the handler, and the renderflags, are tables of fields. For fields in general, see tableOfFields. For the renderflags table in particular, see renderFlags.

--[[
Intercept the user's semicolon keystrokes which toggle the equatorial grid.
]]

local function keyHandler(t)
	assert(type(t) == "table" and type(t.char) == "string")

	if t.char == ";" then
		local flags = celestia:getrenderflags()
		if flags.grid then
			--Grid is on,
			--and will be turned off after the keyHandler returns.
			celestia:print("Equatorial grid off.")
		else
			--Grid is off,
			--and will be turned on after the keyHandler returns.
			celestia:print("Equatorial grid on.")
		end
	end

	return false	--Let the keystroke be executed as a command.
end

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:getobserver()
	local position = celestia:newposition(0, 0, 1)
	observer:setposition(position)

	celestia:registereventhandler("key", keyHandler)
	celestia:print("Press semicolon to toggle the equatorial grid.", 10)
	wait()
end

main()

Mouse Handlersmouse handler

Handlers can be registered for the "mousedown" and "mouseup" events. For "mousemove", see luaHookFunctions. The x, y coördinates are nonnegative integers measured in pixels from an origin in the upper left corner of the Celestia window. Returning true from the handler will prevent Celestia from selecting the object that was clicked on. A missing return statement in a mouse handler is equivalent to returning false. local function mousedownHandler(t) assert(type(t) == "table" and type(t.button) == "number" and type(t.x) == "number" and type(t.y) == "number") celestia:print("mouse button number = " .. t.button .. " at pixel (" .. t.x .. ", " .. t.y .. ")", 5) return false --Let the click select an object. end

Nine Lua Hook FunctionsLua hook

Functions can be called automatically on nine more occasions, represented by the following strings. "charentered"
"keydown"
"mousebuttondown"
"mousebuttonmove"
"mousebuttonup"
"mousemove"
"renderoverlay"
"resize"
"tick"
These functions, called Lua hooks, must be written in a separate file. Our convention is to name the file luahook.celx and place it in the Celx directory (celxDirectory). The filename is specified by placing the following parameter within the {curly braces} of the Configuration section of celestia.cfgcelestia.cfg (configuration file). (The parameter will have to be rewritten with a slash if a Lua hook calls the methods Celestia:loadtexture or Celestia:runscript.) #excerpt from celestia.cfg LuaHook "luahook.celx"

luahook.celx is executed once, when Celestia is launched. It creates a table containing the hooks and passes it to the method Celestia:setluahook. The hooks will be called automatically at the appropriate times. When a hook is called, the table is passed to the hook as the self argument of the hook. Using this argument, the hook can access all of the table’s fields. The table acts like an objectobject (in programming language) in an object-oriented language: the hooks are the object’s methods and the other fields of the table are the object’s instance variables. See Geostationary for a similar “object” implementing a scripted orbit.

On all platforms, the charenteredcharentered (Lua hook) hook receives all normal characters. On Macintosh, it also receives command characters but not control characters. On Windows, it receives control characters. A Mac command character or a Windows control character is received by charentered as a three-character string such as the "C-h" we saw with the "key" event handler in keyHandler. The keystrokes must be typed into the Celestia window, not the command window from which Celestia was launched.

The keydownkeydown (Lua hook) hook is called for special keys: the four arrows, Home and End, the numeric keypad, some of the function keys, and the letters a and z. See orbitObserver for an example with the arrow keys.

The arguments of mousebuttondownmousebuttondown (Lua hook), mousebuttonupmousebuttonup (Lua hook), and mousemovemousemove (Lua hook) are the x, y coördinates of the mouse. They are nonnegative integers measured from an origin at the upper left corner of the window. On most platforms, the arguments of mousebuttonmove are the change in the coördinates since the last call to mousebuttonmove. These numbers will be negative for a move to the upper left, but will always be close to zero. We will use these numbers in opengl to drag a graphic across the window.

mousebuttonmove makes little sense in Microsoft Windows. It is called for every mouse move, even when the mouse button is not pressed. Before the first press, its arguments give the offset from the origin in the upper left corner. After the mouse button is pressed and released at the same position, the arguments give the offset (positive or negative) from that position. While the mouse is being dragged, the arguments are zero and zero. After a drag, the arguments are the offset of the mouse from the center of the Celestia Window.

The renderoverlayrenderoverlay (Lua hook) hook is called each time the transparent overlay on the Celestia window is rendered. This happens many times per second, approximately with each tick of the simulation. The overlay contains text such as the name of the selected object in the upper left corner, the current time in the upper right, and the output of Celestia:print. All OpenGL graphics must be drawn in renderoverlay or in a tick handler (tickHandler). See the examples in opengl Analemma, Sundial, and project.

The resizeresize (Lua hook) hook is called when the Celestia window is resized, including the initial sizing when the window is created. The width and height are nonnegative integers measured in pixels.

Most hooks return the value false by default. This causes the keystroke or mousemove that triggered the hook to have its normal effect after the hook has finished. For example, a keystroke will be executed as one of the keystroke commands in keystroke. A return value of true allows nothing else to happen. It indicates that the keystroke or mouse move has been completely serviced by the hook, and that no further action is necessary.

--[[
This file is luahook.celx.
Create a table named luaHook containing 18 fields.
The values of the first 9 fields are strings.
The values of the last 9 fields are methods.
]]

local luaHook = {
	charenteredString = "",
	keydownString = "",
	mousebuttondownString = "",
	mousebuttonmoveString = "",
	mousebuttonupString = "",
	mousemoveString = "",
	renderoverlayString = "",
	resizeString = "",
	tickString = "",


	charentered = function(self, char)
		assert(type(self) == "table" and type(char) == "string")
		self.charenteredString = "charentered(\"" .. char .. "\")"

		--The default; let a character or a control character
		--be executed as a keystroke command.
		return false
	end,


	keydown = function(self, key, modifiers)
		assert(type(key) == "number" and type(modifiers) == "number")
		self.keydownString = "keydown(" .. key .. ", " .. modifiers
			.. ")"
		if key ~= 0 then
			self.keydownString = self.keydownString
				.. " \"" .. string.char(key) .. "\""
		end

		--The default; let a function key or numeric keypad digit (try
		--"4" and "6") be executed as a keystroke command.
		return false
	end,


	mousebuttondown = function(self, x, y, button)
		assert(type(button) == "number")
		self.mousebuttondownString = "mousebuttondown(" .. x .. ", "
			.. y .. ", " .. button .. ")"
		return false	--the default; let user drag border of splitview
	end,


	mousebuttonmove = function(self, dx, dy, modifiers)
		assert(type(modifiers) == "number")
		self.mousebuttonmoveString = "mousebuttonmove(" .. dx .. ", "
			.. dy .. ", " .. modifiers .. ")"
		return false	--the default; let user drag on sky
	end,


	mousebuttonup = function(self, x, y, button)
		assert(type(button) == "number")
		self.mousebuttonupString = "mousebuttonup(" .. x .. ", " .. y
			.. ", " .. button .. ")"
		return false	--the default; let user select the object
	end,


	mousemove = function(self, x, y)
		self.mousemoveString = "mousemove(" .. x .. ", " .. y .. ")"
		return false	--the default; let user drag on sky
	end,


	renderoverlay = function(self)
		self.renderoverlayString = "renderoverlay()"

		--When printing in upper left corner of window, 6th argument
		--must be <= -1 to avoid chopping off top line of text.
		celestia:print(
			self.charenteredString .. "\n"
			.. self.keydownString .. "\n"
			.. self.mousebuttondownString .. "\n"
			.. self.mousebuttonmoveString .. "\n"
			.. self.mousebuttonupString .. "\n"
			.. self.mousemoveString .. "\n"
			.. self.renderoverlayString .. "\n"
			.. self.resizeString .. "\n"
			.. self.tickString,
			1, -1, 1, 1, -1)

		--Return value is ignored.
	end,


	resize = function(self, width, height)
		self.resizeString = "resize(" .. width .. ", " .. height .. ")"
		--Return value is ignored.
	end,


	tick = function(self, dt)	--dt is seconds since last tick
		self.tickString = "tick(" .. dt .. ")"
		if dt ~= 0 then
			self.tickString = self.tickString
				.. " ticks per second = " .. 1 / dt
		end
		--Return value is ignored.
	end
}

celestia:setluahook(luaHook)
--Don't call the wait function in this file.

Launch Celestia, either with or without a Celx program. Press the keys, move the mouse, and resize the window.

charentered(";") keydown(59, 0) ";" mousebuttondown(576, 216, 1) mousebuttonmove(0, -1, 1) mousebuttonup(576, 216, 1) mousemove(576, 216) renderoverlay() resize(683, 465) tick(0.016816139221191) ticks per second = 59.466681789826

Warning 1. Always check the Celestia console (console) when using Lua hooks, because that’s the destination for error messages produced by the assert and error (assert) functions in luahook.celx. Be careful of uppercase vs. lowercase mistakes. As usual, nonexistent variables and functions are given the value nil, which will probably trigger additional errors—sixty times per second.

Warning 2. There is no direct way for the regular Celx program (e.g., hello.celx) to communicate or synchronize its actions with the hooks in luahook.celx. A trick you could try is the Celestia:settimescale workaround in traceRetrograde. It may be possible to make part or all of the problem disappear by writing code in a tick handler (tickHandler) rather than in the "tick" Lua hook.

Warning 3. Celestia creates the table of Lua hooks very early in the startup process, before the solar system catalog (.ssc) files are read. Therefore Celestia:find cannot be called when the hooks are being created. find can be called later, however, when the hooks are called.

#Excerpt from Luahook.celx. --local sol = celestia:find("Sol") --premature local luaHook = { --myField = celestia:find("Sol"), --premature myField = nil, charentered = function(self, char) --By the time the hook is called, it's safe to call find. self.myField = celestia:find("Sol") end } celestia:setluahook(luaHook)

A similar technique must be employed to “require” the Celx standard library in luahook.celx. See standard.

How many times per second is the renderoverlay hook called? Call the method Celestia:getscripttime which, in luahook.celx, returns the number of seconds since luahook.celx was first read.

Verify that the celestia object in luahook.celx is a different object than the celestia object in the main Celx program. Thus there are actually two object of class Celestia, contradicting what we said in objectsAndClasses. Unfortunately, this eliminates one possible means of communication between the regular Celx program and the Lua hooks.

--[[ Display the ID number of this celestia object. Temporarily hide the boring "tostring"tostring (Lua function) function that renders a celestia object as the uninformative string "[Celestia]". With that function out of the way, we automatically uncover a better "tostring" function that displays the ID number of the celestia object. ]] local metatablemetatable (Lua) = getmetatablegetmetatable (Lua function)(celestia) local save = metatable.__tostring --two underscores metatable.__tostring = nil --Call the better "tostring" function. local s = "This celestia object is " .. tostring(celestia) --Restore the boring "tostring" function. metatable.__tostring = save This celestia object is userdata: 0x8e453b4

Verify that luahook.celx and the main Celx program are executed by two different Lua threadsthread. In Lua 5.1, the function coroutinecoroutine.running returns only one value.

local s = "This code is being executed by " .. tostring(coroutine.running()) This code is being executed by thread: 0x9104a10

Interactions between the Keyboard Functions

When a key is pressed, up to four Celx functions can be called automatically. In addition, the key can also be executed as a Celestia keystroke command. These functions are called in the following order.

  1. the celestia_keyboard_callback in callback
  2. the "key" event handler in keyHandler
  3. the charentered Lua hook in luaHookFunctions
  4. the keydown Lua hook in luaHookFunctions
  5. the key is executed as a Celestia keystroke command in keystroke

Each of the first four functions can return true to prevent the subsequent steps from happening. This value means that the keystroke that triggered the function call has been completely serviced by the function, and no further action is necessary. One exception: the keydown hook, if present, is always called.

The Celx Standard LibraryCelx Standard Library

There will probably be a significant library of useful functions that can be defined purely in Lua.
Comment in the member function LuaState::init in
version/src/celestia/celx.cpp

This book presents a Standard Library of classes, functions, and objects to provide the values and services most commonly needed in Celx programming. For example, the standard position at the start of many programs, celestia:newposition(0, 0, 1), has been created once and for all in the library as the object std.position. The prefix “std.” means that the position belongs to a table named std. See tableOfFields for tables.

The features offered by the library fall into three groups.

  1. The library contains standard utility functions, such as conversions between Cartesian and polar coördinates or between characters and their UTF-8 representation. It also contains useful objects such as the safe position std.position and the vector std.xaxis, convenient arrays such as std.monthName and std.zodiac, and physical constants such as std.pixelsPerIn and std.tilt (the tilt of the Earth’s axis in radians, approximately 23°).
  2. Celx provides two classes, Position (position) and Vector (vectors), that contain the same Cartesian coördinates but have to be handled differently. For example, the method Frame:from accepts a Position but not a Vector, while Rotation:transform accepts a Vector but not a Position. The library makes Position and Vector more interchangeable.
  3. Celestia supplies many “frames of reference” or coördinate systems; see framesOfReference. The library can implement a new frame as a rotation of a frame supplied by Celx, keeping the origin at the same point. For example, right ascension and declination coördinates (j2000Equatorial) are easily implemented as a 23° rotation of Celx’s “universal” frame (universalFrame). And altitude and azimuth coördinates (celestialDome) for a given point on a given planet are implemented as a rotation of Celx’s “bodyfixed” frame for that planet (bodyfixedFrame); the particular rotation will depend on the point’s latitude and longitude. These coördinate systems are implemented with the library class RotatedFrame.

The source code of the library is the file in stdCelx. To install it, store it in a plain text file named std.luastd.lua (file) in the Celx directory (celxDirectory). After creating or modifying this file, Celestia must be relaunched.

To access the library, the following program calls the Lua function requirerequire (Lua function). The parentheses( ) (omitted for string argument) are not needed when the function argument is a double-quotedquotes around string string, so we could have omitted them. But the Celx programmer probably has to switch back and forth on an hourly basis to languages where the parentheses are required, so we leave them in. To verify that the library has been loaded, we check that there is a field for it in the package.loadedpackage.loaded table.

The method saneCelestia:sane is added to class Celestia when we require the standard library. It was inspired by the Unix command stty sane and resets the Celestia options to sane values. It displays the planets but not their orbits, the moons but not the asteroids, and the equatorial grid but not the ecliptic grid. For convenience, it returns the observer object.

--[[
Demonstrate that a .celx program can use the Celx Standard Library std
in the file std.lua.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.

	if package.loaded.std == nil then
		--require was unable to load the standard library.
		--We can't mention anything that starts with std.
		local observer = celestia:getobserver()
		observer:setposition(celestia:newposition(0, 0, 1))
	else
		--The standard library loaded successfully.
		--It's safe to use all the things that start with std.
		local observer = celestia:sane()
		observer:setposition(std.position)

		local s = "Celx Standard Library version " .. std.libraryVersion
		celestia:print(s, 60, -1, 1, 1, -1)	--upper left corner
	end

	wait()
end

main()
Celx Standard Library version 1.1

A C programmer has a rage to cram everything into a single expression. Since the observer is mentioned only once, we can telescope the first two statements in the else to the following.

celestia:sane():setposition(std.position)

Does this make the program harder to understand? If so, the C programmer would smile.

As the standard library is created, it calls Celestia:getobserver. But the luahook.celx file (luaHookFunctions) is executed before any observers are created. This means that luahook.celx must not require("std") when Celestia starts up. The library can, however, be required later, when the hooks are called. To ensure that it is required only once, use the following if. More examples are in Analemma, Sundial, project, and scriptedOrbit.

--[[
This file is luahook.celx.
Can't require("std") up here at the top of the file.
]]

local luaHook = {
	renderoverlay = function(self)
		if package.loaded.std == nil then  --standard lib not loaded yet
			--But we can require("std") down here.
			require("std")
		end

		--Demonstrate that renderoverlay can use std.
		celestia:print("std.libraryVersion = " .. std.libraryVersion, 1)
	end
}

celestia:setluahook(luaHook)
std.libraryVersion = 1.1

Normally we would have named the library file std.celx since it contains Celx code. But the require function looks for a file whose extension is .lua. The require gets this extension from the Lua string package.path, which by default contains the following pattern. ./?.lua The first dot. (current directory) stands for the current directory (the Celx directory). The slashslash (in filename) (or backslashbackslash (in filename) on Microsoft Windows) separates the directory name from the filename. The question mark? (wildcard in package.path) is a wildcardwildcard (in package.path) that will match the string "std" passed to require. The second dot is part of the .lua filename extension.

If we wanted to name the standard library file std.celx, we could override the default the contents of package.path by creating an operating system “environment variable”environment variable named LUA_PATHLUA_PATH (environment variable) (uppercase with underscore) containing the string ./?.celx (On Microsoft Windows, it would have a backslash.)

But we’re not creating the environment variable, and I want to explain why. It is true that the environment variable would override the default contents of the package.path used by the require in a regular Celx program. But the environment variable would not override the default contents of the package.path used by a require in a luahook.celx file (luaHookFunctions) or in a scripted orbit or rotation file (scriptedOrbit). So we’re going to let the name of the library file remain std.lua.

--[[
Display the environment variable LUA_PATH and the Lua string package.path.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	celestia:requestsystemaccess()	--Get permission to mention os.
	wait()

	local s = nil
	local LUA_PATH = os.getenv("LUA_PATH")

	if LUA_PATH == nil then
		s = "LUA_PATH does not exist."
	else
		--Put double quotes around it,
		--and a backslash in front of each double quote inside it.
		s = string.format("LUA_PATH = %q", LUA_PATH)
	end

	--Change each semicolon to a newline
	--to display each pattern in package.path on a separate line.
	s = s .. "\n\n" .. package.path:gsubstring.gsub(";", "\n")

	celestia:print(s, 60, -1, 1, 1, -1)	--upper left corner of window
	wait()
end

main()

The following are the outputs on Macintosh and Microsoft Windows.

LUA_PATH does not exist. ./?.lua /usr/local/share/lua/5.1/?.lua /usr/local/share/lua/5.1/?/init.lua /usr/local/lib/lua/5.1/?.lua /usr/local/lib/lua/5.1/?/init.lua LUA_PATH does not exist. .\?.lua C:\Users\Myname\AppData\Local\Celestia\lua\?.lua C:\Users\Myname\AppData\Local\Celestia\lua\?\init.lua C:\Users\Myname\AppData\Local\Celestia\?.lua C:\Users\Myname\AppData\Local\Celestia\?\init.lua

Output and Input

We covered keyboard input and mouse input in callback, eventHandler, and luaHookFunctions because we classified them as language idiosyncrasies. This section covers the more conventional types of i/o.

PrintCelestia:print on the Celestia Window

The method Celestia:print prints on the transparent overlay on the Celestia window. The color defaults to white and can be overridden by Celestia:settextcolor. The same color should be returned by Celestia:gettextcolor, but it rounds each rgb component to the nearest 255th. For example, the .5 in the following program is rounded to 127/255 ≈ 0.498039. The alpha level is hardwired to 1 (totally opaque).

The font and size are specified by the TitleFontTitleFont (parameter in celestia.cfg) parameter in celestia.cfg: #Excerpt from celestia.cfg. #The file sansbold20.txf is in the fonts subdirectory of the Celx directory. #Don't forget the .txf extension ("texture font") and the double quotes. TitleFont "sansbold20.txf" The TitleFont defaults to the font specified by the another parameter, FontFont (parameter in celestia.cfg), which defaults to the font default.txfdefault.txf (font) in the fonts subdirectory of the Celx directory (celxDirectory). The font cannot be changed while the Celx program is running. To see all the glyphs in the font, see inputFile. For other uses of fonts, see the Celestia console in console, the OpenGL graphics in opengl, and the labels set by Object:mark.

The following program prints the basic statistics about the color, font, and size of Celestia:print. The %8g format prints at least eight characters. For example, the number 1 will have seven blanks in front of it.

--[[
Print the color and the dimensions of an "em" in the font used by
Celestia:print.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	celestia:settextcolor(.5, 1, 1)	--red, green, blue; alpha is 1
	local red, green, blue = celestia:gettextcolor()

	local name = celestia:getparamstring("TitleFont")	--case sensitive
	assert(type(name) == "string")
	if name == "" then	--not found
		name = celestia:getparamstring("Font")
	end
	if name == "" then
		name = "default.txf"
	end

	local font = celestia:loadfont("fonts/" .. name)
	--another way to get the same font:
	--local font = celestia:gettitlefont()
	assert(type(font) == "userdata" and tostring(font) == "[Font]")

	local mWidth = font:getwidth("M")	--Argument can be > 1 character.
	local mHeight = font:getheight()
	assert(mHeight > 0 and mWidth > 0)	--Prevent division by zero.
	local windowWidth, windowHeight = celestia:getscreendimension()

	local s = string.format(
		   "red   = %8g = %3d/255\n"
		.. "green = %8g = %3d/255\n"
		.. "blue  = %8g = %3d/255\n"
		.. "%s: one em is %d x %d pixels.\n"
		.. "Window is %d x %d pixels.\n"
		.. "Window therefore has room for %d lines of %d ems.",

		red, 255 * red,
		green, 255 * green,
		blue, 255 * blue,

		name, mWidth, mHeight,
		windowWidth, windowHeight,
		math.floor(windowHeight / mHeight),
		math.floor(windowWidth / mWidth))

	celestia:print(s, 60, -1, 1, 1, -1)	--upper left corner of window
	wait()
end

main()
red = 0.498039 = 127/255 green = 1 = 255/255 blue = 1 = 255/255 sansbold20.txf: one em is 16 x 24 pixels. Window is 640 x 480 pixels. Window therefore has room for 20 lines of 40 ems.

The first argument of Celestia:print is the string to be printed, at most 210 − 1 = 1023 characters. A longer string will not be printed. There is no need for the string to end with the newline character "\n". For special characters, see specialCharacters.

The second argument is the number of real-time seconds during which the text remains in the window. It defaults to 1.5, including a half-second fadeout whose duration is hardwired into the member function CelestiaCore::renderOverlay in version/src/celestia/celestiacore.cpp. To prevent the program from advancing to the next statement while the text is visible, follow the print with a wait of the same duration. local duration = 5 celestia:print("Your message here.", duration) wait(duration) --until the print fades away --Or you could use std.duration, which is also 5. To keep the text visible forever, or until overwritten by another call to Celestia:print, use a duration of math.huge.

The remaining arguments of Celestia:print are optional pairs of integers specifying a location in the window. The first pair represents the origin for the text. Each integer should be 0 or ±1.

-1,  1  upper left  0,  1  top  1,  1  upper right
-1,  0  left  0,  0  center  1,  0  right
-1, -1  lower left  0, -1  bottom  1, -1  lower right

The next pair of integers is the horizontal and vertical offsets from the text origin, measured in ems. Fractions will be ignored. The two pairs default to -1, -1, 0, 5, representing a point five lines above the lower left corner of the window. Celestia:flash prints at the same place.

Warning: with an origin at the top of the window, the vertical offset from the origin must be less than or equal to –1 to prevent the top line of text from being cut off. In other words, if the fourth argument is 1, the sixth argument must be less than or equal to –1.

Use the OpenGL functions in opengl for control over the alpha level and for pixel-level control over the location of the text. OpenGL also permits tilted and superimposed strings, multiple fonts and colors at the same time, and strings longer than 1023 characters. To print on a window divided into multiple views, see the splitview example in Iapetus.

The center of the window is the direction of the observer’s view. Let’s mark this point with a pair of characters. The exact center will be between the characters, at their baseline. The first character has to be an uppercase M because the fifth argument of Celestia:print is in ems. If you can’t see the characters, drag the Sun away from the center of the window.

celestia:print( "MM", math.huge, 0, 0, --center of window -1, 0) --indent by the negative of the width of one uppercase M wait() --No text appears until we call wait. --Easier way to do the same thing. --With no arguments, it prints only the "MM". celestia:mmprint() wait()

To mark the center of the window with crosshairs, see opengl.

LogCelestia:log to the Celestia Console

The Celestia debugging console displays the standard error output produced by Celestia itself. The console also displays the text written by a Celx program with the method Celestia:log. The method accepts one string argument, to which a newline will be automatically appended. Unlike Celestia:print, a call to Celestia:log does not overwrite the previous call’s text.

The console has room for only 200 lines of text, a default overridden by the LogSizeLogSize (parameter in celestia.cfg) parameter in celestia.cfg. (Actually, one less: 199.) A line longer than 120 characters will be split, counting as two separate lines for the 200-line limit.

The color (white) and alpha level (1) are hardwired into the member function CelestiaCore::draw in version/src/celestia/celestiacore.cpp. The font is specified by the FontFont (parameter in celestia.cfg) parameter in celestia.cfg: #Excerpt from celestia.cfg. Font "sans12.txf" It defaults to default.txf.

Let’s list the image files that are loaded when we go to the Earth:

--[[
Go to the Earth, causing its texture (image) files to be loaded.
]]
require("std")

local function main()
	local observer = celestia:sane()

	celestia:log("About to go to Earth.  The console font is "
		.. celestia:getparamstring("Font") .. ".")

	local earth = celestia:find("Sol/Earth")
	observer:goto(earth, std.duration)	--std.duration is 5 seconds.
	wait(std.duration)			--Wait until goto is finished.

	celestia:log("We have gone to Earth.")
	wait()
end

main()

Press ~ (tilde) to toggle the console, the up and arrow arrows to scroll it, and Page Up and Page Down to scroll ten lines at a time.

About to go to Earth. The console font is sans12.txf. Loading image from file textures/medres/earth.png Creating ordinary texture: 2048x1024 Loading image from file textures/medres/earthnight.jpg Creating ordinary texture: 1024x512 Loading image from file textures/medres/earth-clouds.png Creating ordinary texture: 1024x512 Loading image from file textures/medres/moon.jpg Creating ordinary texture: 1024x512 Loading image from file textures/medres/moonbump.jpg Creating ordinary texture: 1024x512 We have gone to Earth.

Write to the Standard Outputstandard output and Standard Error Output

The text written by Celestia:print and Celestia:log can easily be read by a human being. But it would be hard for another program to read this text, since it is written in pixels in a window. Data intended for consumption by another program, or for storage in a file, should be written to Celestia’s standard output and standard error outputstandard error output. Standard output is for successful output; standard error is for error messages.

Standard output is produced by the Lua function printprint (Lua function), not to be confused with the Celx method Celestia:print. The Lua print concatenates its arguments and follows them with a newline. To avoid the tabs and newline, call the writewrite (method of file) method of the object io.stdoutio.stdout (outputFile). Standard output can also be produced by the method Object:setatmosphere, and by the keystroke commands @ and ! in Celestia’s “super-secret edit mode”. Standard error output is producted by the write method of io.stderrio.stderr. Upon success, write returns true in Lua 5.1, and returns the io.stdout or io.stderr object in Lua 5.2. For access to the variables io and os, see osExit.

Sometimes a Celx program is run only for the purpose of producing standard output. This is often the case when the program is launched from a Unix shellscript, or when its standard output is directed into an output file or pipepipe (Unix). In this situation, we usually want the program to quit Celestia with the function os.exitos.exit (osExit) when the output is finished.

The standard output written by print might not go to the outside world immediately. It could be held in an area of memory inside Celestia called a bufferbuffered i/o. Successive calls to print will fill up more and more of the buffer. When the buffer is full, all of its data is flushed to the outside world in one big convoy. The buffer is also flushed automatically by os.exit when the program ends. The standard error output is unbuffered and requires no flush.

A Celestia that is launched from the command line and that produces buffered output such as standard output must be allowed to die a natural death, either by selecting Quit or Exit from Celestia’s File menu or by calling os.exit. If Celestia is launched from the command line and then assassinated with a control-ccontrol-c (keystroke), the last buffer of standard output might be trapped inside the dying Celestia and might never emerge.

--[[
Demonstrate standard output (for good news)
and standard error output (for bad news).
]]

local function main()
	--Get permission to mention io and os.
	celestia:requestsystemaccess()
	wait()

	if io == nil or os == nil then
		celestia:print("Cannot demonstrate standard i/o or os.exit.",
			10)
		wait()
	else
		--No newline is appended implicitly.
		local r = io.stdout:write("This is standard output ")
		assert(r)	--r is boolean in Lua 5.1

		--Newline is appended implicitly.
		print("and more standard output on the same line.")

		--No newline is appended implicitly.
		r = io.stderr:write("This is standard error output.\n")
		assert(r)

		--Before the Celestia window disappears,
		--give the user time to see that it has no text.
		wait(5)
		os.exit(0)	--flushes the output buffer
	end
end

main()
This is standard output and more standard output on the same line.

Most forms of output are directed to a destination specified (at least implicitly) in the program itself: Celestia:print outputs to the window and Celestia:log to the console. But the standard output and standard error output are sent to destinations specified in the command line that launched the program. Let’s take the Macintosh Terminal as an example; the other command lines follow the same rules.

  1. With no i/o redirection, the Celx program’s standard output and standard error output are directed to the command window itself. (A Celestia compiled --with-glutGLUT (OpenGL Utility Toolkit) also requires a -f argument before the program name.) Celestia progname.celx This is standard output and more standard output on the same line. This is standard error output.
  2. The standard output can be directed to a file on the disk, while the standard error output goes to the command window. Celestia progname.celx >> (i/o redirection) out.txt This is standard error output.
  3. Conversely, the standard error output can go to a file while the standard output goes to the command window. There is no space between the 2 and the >. Celestia progname.celx 2>2> (i/o redirection) err.txt This is standard output and more standard output on the same line.
  4. We can direct the outputs to two separate files. Celestia progname.celx > out.txt 2> err.txt
  5. Or we can combine both outputs in one same file. Celestia progname.celx > same.txt 2>&12>&1 (i/o redirection)

On Microsoft Windows, the standard output and standard error output of Celestia vanish into thin air. This is because Celestia never creates a Microsoft consoleconsole (Microsoft Windows) by calling the AllocConsole function. The workaround is to write the data to an output file (outputFile).

Verify that print prints only 14 of a number’s significant digits. See doubleArithmetic and defaultFormat. To get all 15 of them, we must format the number with the Lua function string.format. See floatFormat.

local x = 1234567890.12345 print(x) local s = string.format("%.15g", x) print(s) 1234567890.1235 1234567890.12345

Read from the Standard Inputstandard input

For Unix programmers, we present a filterfilter, Unix that copies its standard input to its standard output. As the standard input flows through the program, it is also copied to the Celestia window.

By default, the standard input comes from the keyboard. In this case, the keystrokes must be typed into the shell window, not the Celestia window, and we must press return at the end of each line. The standard input can also come from a pipe. To make the pipe work without human intervention, set the ScriptAccessPolicy in celestia.cfg to "allow" and relaunch Celestia.

Both waitwaits around the following call to readread (method of file) are necessary to keep the string s up to date. If either one is missing, the string lags behind the characters as they are typed. If both are missing, the tick handler is never called.

On Microsoft Windows, the read method always returns nil. The standard output is broken too; see standardOutput.

--[[
Copy the standard input to the standard output.  Along the way, carbon copy it
into the Celestia window, like the Unix utility teetee (Unix utility).
]]
require("std")

--variable used by tickHandler:
local s = ""

local function tickHandler()
	celestia:print(s, 1, -1, 1, 1, -1) --upper left, in case s is multi-line
end

local function main()
	local observer = celestia:sane()
	observer:setposition(std.position)
	celestia:requestsystemaccess()     --get permission to mention io and os
	wait()
	celestia:registereventhandler("tick", tickHandler)

	while true do
		wait()
		local c = io.stdinio.stdin:read(1)	--one character
		wait()
		if c == nil then		--end of file
			break
		end
		--Write the standard output in uppercase, so we can tell it
		--apart from the standard input in the shell window.
		io.stdout:write(c:upper())
		s = s .. c
	end

	--Arrive here on end of file.
	celestia:registereventhandler("tick", nil)
	local duration = 5
	celestia:print(s .. "\nEOF", duration, -1, 1, 1, -1)
	wait(duration)
	os.exit(0)
end

main()

Standard input comes from a source specified on the command line that launched the program:

  1. With no i/o redirection, the Celx program’s standard input comes from the keyboard. (A Celestia compiled --with-glut also requires a -f argument before the program name.) Celestia progname.celx
  2. The standard input can come from an input file. Celestia progname.celx < in.txt
  3. The standard input can come from an input pipepipe (Unix). anotherprog | Celestia progname.celx

Write to an Output Fileoutput file

For output to a file whose name is specified in the program itself, we have the conventional trio of openio.open, writewrite (method of file), and closeclose (method of file). The first argument of io.open is the name of the output file to be created, by default in the Celx directory. The second argument is "w" for write. See osExit for access to the variables io and os, and standardOutput for a warning about buffered output.

--[[
Create and open an output file, write to it, and close it.
Check for every checkable error.
]]

local function main()
	--Get permission to mention io and os.

	celestia:requestsystemaccess()
	wait()
	if io == nil or os == nil then
		celestia:print("Cannot demonstrate file output or os.exit.", 10)
		wait()
		return	--Don't execute any more of the main function.
	end

	--Open the file.

	local name = "outfile.txt"
	local handle, message, code = io.open(name, "w")   --create or overwrite
	if handle == nil then
		local s = "couldn't open file " .. name .. "\n"
			.. message .. ", code " .. code .. "\n"
		io.stderr:write(s)
		wait()
		os.exit(1)	--1 indicates failure on my operating system
	end
	assert(type(handle) == "userdata" and io.type(handle) == "file")

	--Write to the file.

	local success, message, code =
		handle:write("This output goes to a file on the disk.\n")
	if success == nil then
		local s = "couldn't write to file " .. name .. "\n"
			.. message .. ", code " .. code .. "\n"
		io.stderr:write(s)
		wait()
		os.exit(1)
	end

	--Close the file.

	local success, message, code = handle:close()
	if success == nil then
		local s = "couldn't close file " .. name .. "\n"
			.. message .. ", code " .. code .. "\n"
		io.stderr:write(s)
		wait()
		os.exit(1)
	end
	assert(type(handle) == "userdata" and io.type(handle) == "closed file")

	--os.executeos.execute("chmod 444 " .. name)   --change mode: only on Unix systems
	wait()
	os.exit(0)		--0 indicates success on my operating system
end

main()

The program creates the file output.txt, containing the following.

This output goes to a file on the disk.

Read from an Input Fileinput file

Let’s read from one of Celestia’s own data files. We’ll pick a .txftxf file (Texture Font) file (an OpenGL txf font) to demonstrate textual input (the first four bytes of the file) and binary input (the rest of the file). The binary numbers are read in 16- and 32-bit sizes, and in big-endianendian (byte order) or little-endian byte order. To assemble the binary numbers, we shift shift the individual bytes into position by multiplying them by a power of 2. For example, multiplying a byte by 28 = 256 shifts it 8 bits to the left. Lua 5.2 will let us shift the bytes with bit32.lshift.

This program also demonstrates random access i/o with file:seekseek (method of file). With the argument "cur", this method skips the designated number of bytes starting from the current position in the file.

The first character in the file is the byte of “all ones”. Lua 5.2 will let us write this character as "\xFF", but Lua 5.1 makes us write it as "\255". The std.utf8 function (specialCharacters) converts a Unicode code number into the string of UTF-8 bytes representing that character.

--[[
Display all the glyphs (characters) that belong to the TitleFont in
celestia.cfg, 16 per line, with the four-hex-digit Unicode number of each glyph.
]]
require("std")

--Read an n-byte integer in binary: 2 or 4 bytes
local reverse = nil	--true if bytes need to be reversed

local function read(handle, n)
	assert(type(handle) == "userdata" and io.type(handle) == "file"
		and (n == 2 or n == 4))
	local s = handle:read(n)
	assert(type(s) == "string" and s:lenstring.len() == n)
	if reverse then
		s = s:reversestring.reverse()
	end
	local b = {s:bytestring.byte(1, n)}		--b is an array of n numbers.
	assert(type(b) == "table" and #b == n)
	local i = 0
	for j = 1, n do
		i = 2^8 * i + b[j]
	end
	return i
end

local function main()
	local observer = celestia:sane()
	local position = celestia:newposition(1, 0, 0)	--Sun not in window.
	observer:setposition(position)

	--Get permission to mention io.

	celestia:requestsystemaccess()
	wait()
	if io == nil then
		celestia:print("Cannot demonstrate file input.", 10)
		wait()
		return
	end

	local name = "fonts/" .. celestia:getparamstring("TitleFont")
	local handle, message, code = io.open(name, "r")	--r for "read"
	if handle == nil then
		local s = "couldn't open file " .. name .. "\n"
			.. message .. ", code " .. code
		celestia:print(s, 60)
		return
	end

	if handle:read(4) ~= "\255txf" then	--a byte of "all ones"
		celestia:print("couldn't read four-byte header in " .. name, 60)
		return
	end

	--Do we have to reverse the bytes of the integers read from the file?
	local b = {handle:read(4):byte(1, 4)}	--b is an array of four numbers.

	if b[1] == 0x78 and b[2] == 0x56 and b[3] == 0x34 and b[4] == 0x12 then
		reverse = true
	elseif b[4] == 0x78 and b[3] == 0x56 and b[2] == 0x34 and b[1] == 0x12
		then
		reverse = false
	else
		local s = string.format(
			"unfamiliar byte order %02X %02X %02X %02X in %s",
			b[1], b[2], b[3], b[4], name)
		celestia:print(s, 60)
		return
	end

	handle:seek("cur", 3 * 4)	--Skip the next 3 four-byte integers.
	local maxAscent = read(handle, 4)
	local maxDescent = read(handle, 4)
	local n = read(handle, 4)	--number of glyphs in font

	local s = string.format(
		"%s reverse: %s\n"
		.. "Max ascent: %u Max descent: %u Number of glyphs: %u\n",
		name, tostring(reverse),
		maxAscent, maxDescent, n)

	for i = 1, n do
		local unicode = read(handle, 2)	--unicode character number
		s = s .. string.format("%04X %s ", unicode, std.utf8(unicode))

		--Display 16 glyphs of the font per line.
		if i % 16 == 0 then	--if i is divisible by 16
			s = s .. "\n"
		end

		--Skip 6 one-byte integers and 2 two-byte integers.
		handle:seek("cur", 6 * 1 + 2 * 2)
	end

	celestia:print(s, math.huge, -1, 1, 1, -1)	--upper left corner
	handle:close()
	wait()
end

main()

On Macintosh and Microsoft Windows, the bytes are reversed. On Microsoft, the second argument of io.open must be "rb" because the input file is binarybinary input file.

fonts/sansbold20.txf reverse: true Max ascent: 19 Max descent: 5 Number of glyphs: 472 0020 0021 ! 0022 " 0023 # 0024 $ 0025 % 0026 & 0027 ' 0028 ( 0029 ) 002A * 002B + 002C , 002D - 002E . 002F / 0030 0 0031 1 0032 2 0033 3 0034 4 0035 5 0036 6 0037 7 0038 8 0039 9 003A : 003B ; 003C < 003D = 003E > 003F ? etc. 2215 ∕ 2219 ∙ 221A √ 221E ∞ 221F ∟ 2229 ∩ 222B ∫ 2248 ≈

Do Celestia’s other fonts have other characters?

The Object:nameObject:name method of a star returns only the star’s most common name:

local betelgeuse = celestia:find("BetelgeuseBetelgeuse (star)") local name = betelgeuse:name() celestia:print(name, 10) Betelgeuse

Write a program that prints all the names of a star. The names are listed in the text file data/starnames.dat, one star per line. For example,

27989:Betelgeuse:Betelgeuze:ALF Ori:58 Ori

The fields are separated by colons. The first field is the HipparcosHipparcos catalog catalog number of the star. The remaining fields are the names, often with alternative spellings. "ALF Ori" (α Orionis) is the Bayer designationBayer designation (of star); "58 Ori" is the Flamsteed designationFlamsteed designation (of star).

The string line holds each line of the file, one by one. The patternpattern (Lua) "[^:]+" fetches the first field on the line. The [[ ] (wildcard in pattern)^^ (in wildcard in pattern):] is a wildcardwildcard (in pattern) that looks for any character that is not a colon. The [^:]++ (in pattern) looks for one or more consecutive characters that are not colons. Since the pattern tries to be as greedy as possible, this will grab the entire first field.

The pattern in the string.gsub grabs the first field on the line, plus the colon that separates the first field from the second field, and removes them. The remaining fields are names, processed one by one by the inner loop.

local star = celestia:find("Betelgeuse") local hipparcos = tostring(star:catalognumber("HIP")) --handle is a handle to the input file data/starnames.dat. Get the name --of this file from the StarNameDatabase parameter in celestia.cfg. for line in handle:lines() do assert(type(line) == "string") --If first field on the line is the Hipparcos number, if line:match("[^:]+") == hipparcos then local s = "Hipparcos " .. hipparcos .. ":\n" line = line:gsubstring.gsub("[^:]+:", "", 1) --remove first field --Loop through the remaining fields. for name in line:gmatch("[^:]+") do assert(type(name) == "string") s = s .. name .. "\n" end local duration = 60 celestia:print(s, duration, -1, 1, 1, -1) --upper left wait(duration) break --exit from the for loop end end Hipparcos 27989: Betelgeuse Betelgeuze ALF Ori 58 Ori

We will package this code as an “iterator”iterator in createIterator.

Error Messages and Assertions

The Lua function errorerror (Lua function) displays an error message and terminates the Celx program, but it lets Celestia keep running. The function accepts one string argument, to which a newline is appended.

--[[
Call the Lua error function if anything goes wrong.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	--Get permission to mention io.
	celestia:requestsystemaccess()
	wait()

	if io == nil then
		error("Cannot demonstrate file input.")
		--Don't call wait here, because we never return from error.
	end

	local handle, message, code = io.open("fonts/default.txf", "r")
	if handle == nil then
		error("couldn't open file " .. name .. "\n"
			.. message .. ", code " .. code)
	end

	local success, message, code = handle:close()
	if success == nil then
		error("couldn't close file " .. name .. "\n"
			.. message .. ", code " .. code)
	end

	wait()
end

main()

The Lua assertassert (Lua function) function provides a more compact notation for the if/then/error/end. Its second argument defaults to the string "assertion failed!".

assert(io ~= nil, "Cannot demonstrate file input.")

Unfortunately, the second argument of assert is evaluated even if the first one is false. The second argument might be an expensive call to string.format.

--Always calls string.format, even if x >= 0. assert(x >= 0, string.format("can't take square of %.15g", x)) local y = math.sqrt(x)

The Standard Library in stdCelx therefore calls error instead of assert.

--Call string.format only when necessary. if x < 0 then error(string.format("can't take square of %.15g", x)) end local y = math.sqrt(x)

In this book we will often write an assert just as concise documentation when introducing a new function, not because we think there is a realistic chance of failure. Plentiful examples were in the Lua hooks in luaHookFunctions.

Where do the assert and error messages go, and the similar messages from syntax errors and undefined variables? On Macintosh, the messages are written to Celestia’s standard output, followed by a dramatic finisfinis on the standard error output. The messages therefore appear only if Celestia is launched from a command line in the Terminal application. On Microsoft Windows, the error messages appear in a modal dialog box entitled “Fatal Error”.

On Linux, a Celestia launched from the comand line will write its error messages to the standard output. A Celestia compiled --with-kdeKDE (K Desktop Environment) will also put up a dialog box with a Details button to display the message. This civilized behavior is the result of the call to CelestiaCore::setAlerter in the constructor for KdeApp in version/src/celestia/kde/kdeapp.cpp. A Celestia compiled with --with-glut, --with-gnome, or --with-gtk will not put up a dialog box. Instead, it will die with a SIGSEGVSIGSEGV (signal) signal (“Segmentation Faultsegmentation fault (core dumpedcore dump)”) because CelestiaCore::setAlerter is never called.

Errors and assertions are handled differently in a callback (callback), event handler (eventHandler), Lua hook (luaHookFunctions), or scripted orbit or rotation (scriptedOrbit). In this case the error message is written to the Celestia console (console), not to the standard output. The function that called error or assert is terminated, but the rest of the Celx program keeps running. (A message from a scripted orbit or rotation may disappear into thin air.)

2D OpenGL GraphicsOpenGL graphics

Celestia is implemented with the OpenGL graphics library, a set of functions for drawing 2D and 3D graphics. Some of them can be called directly by a Celx program. We will use them to plot the stars in the Hertzsprung-Russel diagram (loopDatabase), to draw an analemma (Analemma), to lay out the lines on a sundial (Sundial), and to trace the path of a celestial object (project).

Most of the Celx graphics functions are 2D. In this section, we will just draw a pair crosshairs on the Celestia window. For the third dimension, see the functions gl.Frustum, gl.Ortho, and glu.LookAt in additions.

The OpenGL functions draw on the transparent overlay that covers the Celestia window. To keep the graphics continuously visible, we will have to redraw them every time the overlay is rendered. The drawing will therefore have to take place in a tick handler (tickHandler) or in the tick or renderoverlay Lua hooks (luaHookFunctions). In this book, we’ll use the renderoverlay hook. To erase the graphics, the hook can simply return without drawing anything. The graphics will then disappear in the next sixtieth of a second.

A matrixmatrix (OpenGL) is a rectangle of numbers arranged in rows and columns. OpenGL maintains two stacksstack (OpenGL) of matrices. The matrix at the top of the projection stackprojection stack (OpenGL) controls how the graphics are mapped onto the overlay. We’ll push a plain vanilla identity matrixidentity matrix (OpenGL) onto this stack because we’re doing 2D graphics with no perspective or foreshortening. The matrix at the top of the modelview stackmodelview stack (OpenGL) controls how the graphics are positioned in space. We’ll push another identity matrix to keep the graphics lying flat in the plane of the overlay.

Each push is performed in three steps. First, the function gl.MatrixModegl.MatrixMode designates the stack onto which the matrix will be pushed. Then gl.PushMatrixgl.PushMatrix pushes a copy of the top matrix onto the stack. The two top matrices are now identical. Finally, gl.LoadIdentitygl.LoadIdentity changes the top matrix into an identity matrix. Don’t forget that the pushed matrix must eventually be popped off the stack.

OpenGL uses its own system of x, y coördinates for the pixels. The call to glu.Ortho2Dglu.Ortho2D sets the x coördinates of the overlay’s leftmost and rightmost columns of pixels to 0 and width − 1 respectively, and the y coördinates of the bottom and top rows of pixels to 0 and height − 1. This means that the origin (0, 0) is at the lower left corner of the overlay, with x increasing to the right and y increasing upwards. glu.Ortho2D modifies the identity matrix we pushed onto the projection stack, so it must be called while that stack is still the current one.

We don’t have to keep the origin in the lower left corner. The call to gl.Translate moves the origin to the center of the overlay to make it easier to draw the crosshairs.

The arguments of gl.Colorgl.Color specify an rgb color and an alpha level. With our alpha of .25, the crosshairs will contribute only .25 of the color of the pixels they occupy, while the objects covered by the crosshairs will contribute the remaining 1 − .25 = .75 of the color. These fractions are represented by the constants gl.SRC_ALPHAgl.SRC_ALPHA and gl.ONE_MINUS_SRC_ALPHAgl.ONE_MINUS_SRC_ALPHA.

gl.POINTSgl.POINTS draws a series of separate points (loopDatabase), gl.LINESgl.LINES a series of separate line segments (Sundial), gl.LINE_STRIPgl.LINE_STRIP a series of connected line segments (Analemma and project), and gl.LINE_LOOPgl.LINE_LOOP a series of connected line segments that returns to its starting point (loopDatabase). The following gl.LINES must be followed by an even number of vertices. The x and y coördinates of the vertices are listed between the gl.Begin and gl.End.

--[[
This file is luahook.celx.
Demonstrate OpenGL graphics by drawing vertical and horizontal crosshairs.
]]

local luaHook = {
	renderoverlay = function(self)
		--If it is now more than 60 seconds after Celestia started
		--running, stop calling this method.
		if celestia:getscripttime() > 60 then
			self.renderoverlay = nil
			return
		end

		if package.loaded.std == nil then   --standard lib not loaded yet
			require("std")        --not needed yet, but will be later
		end

		gl.MatrixMode(gl.PROJECTION)
		gl.PushMatrix()
		gl.LoadIdentity()

		--dimensions of the Celestia window, in pixels
		local width, height = celestia:getscreendimension()
		glu.Ortho2D(0, width, 0, height)   --left, right, bottom, top

		gl.MatrixMode(gl.MODELVIEW)
		gl.PushMatrix()
		gl.LoadIdentity()

		--The default origin (0, 0) is at the lower left corner of the
		--window.  Move the origin to the center.
		gl.Translate(width / 2, height / 2)

		gl.Enable(gl.BLEND)
		gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
		gl.Disable(gl.LIGHTING)  --Make text and graphics self-luminous.
		gl.Disable(gl.TEXTURE_2D)	--disabled for graphics
		gl.Color(0, 1, 1, .25)		--red, green, blue, alpha
		gl.LineWidth(1)  --In pixels.  Defaults to 1; can have fraction.

		gl.Begin(gl.LINES)
						--Horizontal crosshair goes
		gl.Vertex(-width / 2, 0)	--from middle of left edge
		gl.Vertex( width / 2, 0)	--to middle of right edge.

						--Vertical crosshair goes
		gl.Vertex(0, -height / 2)	--from middle of bottom edge
		gl.Vertex(0,  height / 2)	--to middle of top edge.
		gl.End()

		gl.MatrixMode(gl.MODELVIEW)
		gl.PopMatrix()

		gl.MatrixMode(gl.PROJECTION)
		gl.PopMatrix()
	end
}

celestia:setluahook(luaHook)
--[[
Provide an unobtrusive background for the OpenGL graphics.
]]
require("std")

local function main()
	local observer = celestia:sane()
	observer:setposition(std.position)
	wait()
end

main()

In project we will establish a connection between the two-dimensional overlay and the three-dimensional simulated universe beyond it. Until then, the graphics will not be aware of the celestial objects in the universe.

Resize the window by dragging on its lower right corner. Observe that the crosshairs remain centered.

Draw one red pixel at the origin. Insert the following code immediately after the above gl.End. The gl.POINTS can be followed by any number of vertices, but we will follow it with just one.

--Draw one red pixel at the origin. gl.Color(1, 0, 0, 1) gl.Begin(gl.POINTS) gl.Vertex(0, 0) gl.End()

Draw a green triangle. Insert the following code immediately after the gl.End of the red pixel. Other simple shapes (square, diamond, triangle) can be found in the member function MarkerRepresentation::render in version/src/celengine/marker.cpp.

--Draw a green right triangle. gl.Color(0, 1, 0, .25) gl.Begin(gl.POLYGON) --filled-in polygon gl.Vertex(0, 0) gl.Vertex(100, 0) gl.Vertex(100, 100) gl.End()

Draw a series of quadrilaterals. Insert the following code immediately after the gl.End of the green triangle.

gl.Begin(gl.QUADS) --filled-in quadrilaterals gl.Vertex(200, 200) gl.Vertex(210, 200) gl.Vertex(210, 210) gl.Vertex(200, 210) gl.Vertex(300, 200) gl.Vertex(310, 200) gl.Vertex(310, 210) gl.Vertex(300, 210) gl.End()

Let the user drag the triangle with the mouse, at least if we’re not using Microsoft Windows. Add the following x and y fields (tableOfFields) to the luaHook table:

local luaHook = { x = 0, --location of lower left corner of triangle in the overlay y = 0, renderoverlay = function(self) --etc.

Add the following mousebuttonmove method to the luaHook table. The subtraction is necessary because the mouse origin is the upper left corner of the overlay with the Y axis pointing down, while the glu.Ortho2D origin is the lower left corner with the Y axis pointing up. mousebuttonmove does not detect a drag in progress on Microsoft Windows; see luaHookFunctions.

mousebuttonmove = function(self, dx, dy, modifiers) self.x = self.x + dx self.y = self.y - dy return true --drag only the triangle, not the universe end,

The vertices of the triangle will now be

gl.Vertex(self.x + 0, self.y + 0) gl.Vertex(self.x + 100, self.y + 0) gl.Vertex(self.x + 100, self.y + 100)

Make a circle out of a connected series of lines. The coördinates of each point on the circle will be (rcos θ, rsin θ) for some angle θ.

Insert the following code immediately after the gl.End of the green triangle. The line width of 1.5 comes from the enableSmoothLines function in version/src/celengine/render.cpp. The value gl.LINE_LOOP draws a series of connect line segments returning to the starting point.

--Curved lines look better when they're smoothed. if celestia:getrenderflags().smoothlines then gl.Enable(gl.LINE_SMOOTH) --antialiasing gl.LineWidth(1.5) end --radius of circle in pixels local r = 100 --The number of points to connect with lines --is the circumference of the circle in pixels. local n = math.floor(2 * math.pi * r) gl.Begin(gl.LINE_LOOP) for i = 1, n do local theta = 2 * math.pi * i / n --in radians gl.Vertex(r * math.cos(theta), r * math.sin(theta)) end gl.End()

Recall that Celestia:print (celestiaPrint) lets us position a string in terms of a whole number of ems. The gl.Translate function lets us position a string at any pixel in the overlay. For example, the first string below has its lower left corner at the origin in the center of the overlay. The second string has its center at pixel (100, 100).

First give the table a field named font. local luaHook = { x = 0, --location of lower left corner of triangle in the window y = 0, font = nil, renderoverlay = function(self) --etc. Then initialize the field immediately after requiring the standard library:

if package.loaded.std == nil then --standard lib not loaded yet require("std") --the font normally used by Celestia:print local name = celestia:getparamstring("TitleFont") self.font = celestia:loadfont("fonts/" .. name) assert(type(self.font) == "userdata" and tostring(self.font) == "[Font]") end

Finally, insert the following code immediately after the gl.End of the circle. When we print a string, the first and third arguments of glu.Ortho2D must be zero, and the horizontal and vertical translation in effect must be numbers that are 1/8 greater than an integer. The offset of 1/8 ensures that each texeltexel (texture element) of a glyph will be sampled at its middle, not at its edge. If these rules are violated, the left and bottom edged of the characters may be sliced off. The standard library function gl.TexelRoundgl.TexelRound rounds its argument to the closest number that is 1/8 greater than an integer. A tie is rounded up. See the sundial in Sundial for an example.

To measure the translations from the origin, not from the end of the previous string, each gl.Translate is surrounded by a gl.PushMatrix and a gl.PopMatrix. The push provides a clean slate for each translation; the pop discards the slate.

gl.Enable(gl.TEXTURE_2D) --enabled for text gl.Color(1, 1, 1, 1) --origin at center of window local xOrigin = width / 2 local yOrigin = height / 2 local s = string.format("Dimensions of window: %d x %d", width, height) gl.MatrixMode(gl.MODELVIEW) gl.PushMatrix() gl.LoadIdentity() gl.Translate(gl.TexelRound(xOrigin), gl.TexelRound(yOrigin)) self.font:bind() self.font:render(s) gl.PopMatrix() --The gl.MatrixMode(gl.MODELVIEW) and the self.font:bind() --are still in effect here and do not need to be repeated. s = "I" --First quadrant is upper right. local x = xOrigin + 100 - self.font:getwidth(s) / 2 local y = yOrigin + 100 - self.font:getheight() / 2 gl.PushMatrix() gl.LoadIdentity() gl.Translate(gl.TexelRound(x), gl.TexelRound(y)) self.font:render(s) gl.PopMatrix() s = "II" --Second quadrant is upper left. x = xOrigin - 100 - self.font:getwidth(s) / 2 y = yOrigin + 100 - self.font:getheight() / 2 gl.PushMatrix() gl.LoadIdentity() gl.Translate(gl.TexelRound(x), gl.TexelRound(y)) self.font:render(s) gl.PopMatrix() Dimensions of window: 1852 x 1036

One caveat. Strings have to be printed one line at a time: the newline characternewline (character) "\n"\n (newline character) isn’t recognized by OpenGL. Neither is the tab charactertab (character) "\t"\t (tab character) or the backspace characterbackspace (character) "\b"\b (backspace character) To print the text at an angle, see glu.LookAtglu.LookAt.

Display an image file, e.g., the file textures/logo.pnglogo.png (file) that comes with Celestia. In the OpenGL world an image file is called a texturetexture (OpenGL), so give the table a field with that name.

local luaHook = { x = 0, --location of lower left corner of triangle in the window y = 0, font = nil, texture = nil, renderoverlay = function(self) --etc.

Then initialize the field after requiring the standard library:

if package.loaded.std == nil then --standard lib not loaded yet require("std") --initialize self.font here --The filename of the Lua hook file must contain a "/". assert(celestia:getscriptpath():findstring.find("/")) self.texture = celestia:loadtexture("textures/logo.png") assert(type(self.texture) == "userdata" and tostring(self.texture) == "[Texture]") end

Finally, insert the following code immediately after the last gl.PopMatrix of the previous exercise. The gl.LINEAR filter does a better job than the gl.NEAREST filter at mapping the pixels of the texture onto the pixels of the overlay.

self.texture:bind() gl.Enable(gl.TEXTURE_2D) gl.Color(1, 1, 1, 1) --for characters of logo gl.TexParameter(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) gl.TexParameter(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) --in pixels local x = 0 --coordinates of lower left corner of texture local y = 0 local w = self.texture:getwidth() --width and height of texture local h = self.texture:getheight() gl.Begin(gl.QUADS) gl.TexCoord(0, 1) --OpenGL s, t texture coordinates upside down gl.Vertex(x, y) --lower left corner of image gl.TexCoord(1, 1) gl.Vertex(x + w, y) --lower right corner gl.TexCoord(1, 0) gl.Vertex(x + w, y + h) --upper right corner gl.TexCoord(0, 0) gl.Vertex(x, y + h) --upper left corner gl.End()

The method Celestia:loadtexture calls the C++ function celestia_loadtexture in version/src/celestia/celx.cpp, which looks for a slashslash (in filename) in the name of the file containing the Celx code that called Celestia:loadtexture. One way to provide the slash is to write the full pathname of the luahook.celx file in celestia.cfg.

#Excerpt from Configuration section of celestia.cfg on Macintosh. #Change "myname", etc., to the correct names on your system. LuaHook "/Users/myname/Desktop/Celestia.app/Contents/Resources/CelestiaResources/luahook.celx"

A Microsoft Windows pathname has backslashesbackslash (path component separator), not slashes, causing loadtexture to print an error message in the Celestia console (console). Windows people will therefore have to modify celestia_loadtexture and recompile Celestia.

Can you display logo.png upside down by changing the arguments of gl.TexCoord?

Astronomical Numbers

How can we format the digits of an astronomical number? Even more importantly, how many digits are there to format? We raise this issue to forestall any hopeless quest for a level of precision that just isn’t present in the number.

Doubledouble precision floating point Arithmetic

A Celx number is implemented as a C language “double precision floating point number”, known familiarly as a double. This policy is set by the LUA_NUMBERLUA_NUMBER (macro) macros in the luaconf.hluaconf.h (header file) header files in the directories luaversion/src, version/macosx, and version/windows/inc/lua-5.1. The first macro is the most important.

//Excerpt from each luaconf.h file. #define LUA_NUMBER double

On my machine, and probably also on yours, a nonzero double holds a value in one of two possible formats. The normalizednormalized (double) format is presented here; the denormalized in denormalized.

A normalized nonzero double holds a value of the form ± numerator 253 · 2exponent Let’s look at each piece separately. The fraction numerator 253 is called the mantissamantissa (of double) of the number. On my machine, and probably also on yours, the numerator of the mantissa is an integer in the range 252 = 4,503,599,627,370,496 to 253 − 1 = 9,007,199,254,740,991 inclusive. These limits make more sense in binary. 252 = 10000000000000000000000000000000000000000000000000000
253 − 1 = 11111111111111111111111111111111111111111111111111111
We can now see that a numerator is a 53-bit number whose most significant bit is 1. There are 252 possible values for the numerator, and the largest one is practically twice as big as the smallest.

The denominator is fixed at 253 = 9,007,199,254,740,992 so the value of the mantissa is in the range 4,503,599,627,370,496 9,007,199,254,740,992 mantissa 9,007,199,254,740,991 9,007,199,254,740,992 which we can simplify to .5 ≤ mantissa < 1

The exponentexponent (of double) of a double is an integer in the range –1021 to 1024 inclusive, with a base of 2. The values of the numerator, denominator, and exponent of a nonzero double can be isolated with the Lua function math.frexpmath.frexp and the standard library function std.fractionstd.fraction. The limits are available as macros in the C Standard Library header file float.hfloat.h (header file).

//Excerpt from float.h #define FLT_RADIX 2 /* base of exponent */ #define DBL_MIN_EXP (-1021) #define DBL_MAX_EXP (+1024) #define DBL_MANT_DIG 53 /* # of base FLT_RADIX digits in numerator */

The largest possible normalized double has the largest mantissa and the largest exponent:

253 − 1 253 · 21024 = (253 − 1) · 2971 ≈ 1.79769313486231 · 10308

And the smallest positive normalized double has the smallest mantissa and the smallest exponent:

252 253 · 2–1021 = 2–1022 ≈ 2.22507385850720 · 10–308

Of greater practical concern is the number of significant decimal digitssignificant digits a double can hold. These are the digits from the leftmost to the rightmost nonzero digits of the double, inclusive. Here is a number with 15 significant digits:

1234567890.12345

A double can always hold at least 15 significant digits, and sometimes more. Let’s demonstrate this with the fraction ⅓, always the acid test of precision. The double value closest to ⅓ does indeed have at least 15 threes before it breaks up into garbage. In fact, it has 16 of them. 6,004,799,503,160,661 253 · 2–1 = 6,004,799,503,160,661 9,007,199,254,740,992 · 2–1 = 6,004,799,503,160,661 18,014,398,509,481,984


= 0.333333333333333314829616256247390992939472198486328125

--[[
Demonstrate that a double can hold at least the first 15 threes of 1/3.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	local x = 1/3
	local mantissa, exponent = math.frexp(x)
	local denominator = 2 ^ std.mantissa
	local numerator = math.abs(mantissa) * denominator

	local signBit = 0
	if x < 0 then
		signBit = 1
	end

	--The numerator and denominator cannot be formatted with %d, even though
	--they are integers, because they are greater than or equal to 2^31.

	local s = string.format(
		"x =        %.54f\n"
		.. "sign bit = %d\n"
		.. "mantissa = %.54f\n"
		.. "exponent = %d\n"
		.. "numerator   of mantissa = %.0f\n"
		.. "denominator of mantissa = %.0f\n"
		.. "A double with a %d-bit mantissa "
		.. "can hold up to %d significant decimal digits.",
		x,
		signBit, mantissa, exponent,
		numerator, denominator,
		std.mantissa, std.significant)

	celestia:print(s, 60, -1, 1, 1, -1)	--upper left corner
	wait()
end

main()

The Macintosh output was

x = 0.333333333333333314829616256247390992939472198486328125 sign bit = 0 mantissa = 0.666666666666666629659232512494781985878944396972656250 exponent = -1 numerator of mantissa = 6004799503160661 denominator of mantissa = 9007199254740992 A double with a 53-bit mantissa can hold up to 15 significant decimal digits.

The Microsoft Windows output was

x == 0.333333333333333310000000000000000000000000000000000000 sign bit == 0 mantissa == 0.666666666666666630000000000000000000000000000000000000 exponent == -1 numerator of mantissa == 6004799503160661 denominator of mantissa == 9007199254740992 A double with a 53-bit mantissa can hold up to 15 significant decimal digits.

On both platforms, we will therefore format a double with string.format using the %.15g format in floatFormat.

How far apart are consecutive double values? It depends on their size: they get farther apart as they get bigger. Consider the number 2013 = 8,853,267,626,852,352 253 · 211 which belongs to the half-open interval 1024 ≤ x < 2048 The standard notation for this interval is [1024, 2048) and we can write it more simply as powers of 2: [210, 211) The length of the interval is 211 − 210 = 210. There are 252 equally spaced double values that lie in the interval, ranging from 252 253 · 211 = 1024 to 253 − 1 253 · 211 = 2048 – 1 253 These values, all with the exponent 11, divide the interval into 252 equal subintervals. The length of each subinterval is therefore 210 252 = 2–42 = .000000000000227373675443232059478759765625 For example, this length is the distance between 2013 and the next larger and next smaller values that can be stored in a double.

The integer 253 + 1 = 9,007,199,254,740,993 cannot be stored in a double, although its neighbors can.

local x = 2^53 --x can hold 2^53 local y = 2^53 + 1 --y cannot hold 2^53 + 1 local z = 2^53 + 2 --z can hold 2^53 + 2 local s = string.format( "x = %.0f\n" .. "y = %.0f\n" .. "z = %.0f", x, y, z) x = 9007199254740992 y = 9007199254740992 z = 9007199254740994

Let’s prove that 253 + 1 is the smallest positive integer that cannot be stored in a double. Consider the half-open interval [253, 254) The length of this interval is 253, and it is divided into 252 equal subintervals by the 252 double values whose exponent is 54. The length of each subinterval is therefore 253 252 = 2 This length is the distance between 253 + 1 and the next larger and next smaller values that can be stored in a double. In the same way, we can prove that the double values in the previous interval [252, 253) are only 1 unit apart from each other, quod erat demonstrandum.

How much precision at interstellar distances do we get from a double? Let’s take BetelgeuseBetelgeuse (star), some 498 lightyears from the Solar System Barycenter in the Celestia universe (643 lightyears in the Wikipedia universe). We will measure the distance to Betelgeuse from the Solar System Barycenter, rather than from the Sun, in order to get the same distance each time.

The 1ee (exponent notation)-6 stands for

1 · 10–6 = 1 106 = 1 1,000,000
--[[
Print the distance in lightyears from the Solar System Barycenter
to Betelgeuse.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	local barycenter = celestia:find("Solar System Barycenter")
	assert(barycenter:type() == "star" --The barycenter isn't really a star.
		and barycenter:getinfo().stellarClass == "Bary")

	local betelgeuse = celestia:find("Betelgeuse")
	assert(betelgeuse:type() == "star")

	--vector pointing from barycenter to Betelgeuse
	local toBetelgeuse = betelgeuse:getposition() - barycenter:getposition()

	local microlightyears = toBetelgeuse:length()
	local mantissa, exponent = math.frexp(microlightyears)

	local s = string.format(
		   "distance in microlightyears = %.15g\n"
		.. "distance in lightyears      = %.15g\n"
		.. "exponent = %d",
		microlightyears,
		microlightyears * 1e-6,
		exponent)

	celestia:print(s, 60)
	wait()
end

main()
distance in microlightyears = 497964882.348847 distance in lightyears = 497.964882348847 exponent = 29

The Celestia unit of interstellar distance is the microlightyear (linearDistance). In these units, the distance falls into the half-open interval [228, 229) = [268435456, 536870912) The length of this interval is 228, and it is divided into 252 equal subintervals by the 252 double values whose exponent is 29. The length of each subinterval in microlightyears is therefore 228 252 = 2–24 = .000000059604644775390625 A microlightyear is equal to 9,460,730.4725808 kilometers (lightyear), so the length of each subinterval in kilometers is 2–24 · 9,460,730.4725808 = 0.563903479133892059326171875 This roundoff error in our distance to Betelgeuse is only about half a kilometer, vastly smaller than the uncertainty of our knowledge of the distance in the real world. Nonetheless, we will usually print out all 15 significant digits of a double result. We do this to let the reader verify that he or she has exactly duplicated our calculations, not because we claim that our answers are accurate to 15 digits.

How much precision do we get for the star DenebDeneb (star) (α Cygni), 1412 lightyears away in the Celestia universe? For an example with nanometer precision, see positionCoordinates.

The following macro in float.h claims that a double can always hold at least 15 significant decimal digits.

#define DBL_DIGDBL_DIG (macro) 15

Let’s prove it. We will have to show that the double values are more finely grained than the values of numbers with 15 significant decimal digits. In other words, the distance between consecutive double values is less than or equal to the distance between consecutive 15-sigdig numbers.

A positive double x lies in the half-open interval [2y, 2y+1) where y = loglogarithm2 x , the greatest integergreatest integer function that is less than or equal to log2 x. For example, we saw that y = 10 when x = 2013. If x = 2y, the length of this interval is exactly x; and if x > 2y, the length of this interval is less than x. The interval is divided into 252 subintervals by the 252 double values whose exponent is y+1. The length of each subinterval is therefore at most x 252

Now let’s talk about significant decimal digits. A nonzero number with up to 15 significant decimal digits has a value of the following form. ± numerator 1015 · 10exponent The numerator is an integer in the range 1014 = 100,000,000,000,000 to 1015 − 1 = 999,999,999,999,999 inclusive. Thus there are 1015 – 1014 = 9 · 1014 possible numerators. The largest numerator is practically 10 times as big as the smallest one. A positive x lies in the half-open interval [10z, 10z+1) where z = log10 x . (For example, z = 3 when x = 2013.) If x = 10z, the length of this interval is 9x; and if x is slightly less than 10z+1, the length of this interval is slightly greater than .9x. The interval is divided into 9 · 1014 subintervals by the 9 · 1014 numbers with 15 significant decimal digits and the exponent z + 1. The length of each subinterval is therefore always greater than .9x 9 · 1014 = x 1015

So the double values are at least as close together as the 15-sigdig values, because x 252 x 1015 which is true because 1015 = 1,000,000,000,000,000 ≤ 4,503,599,627,370,496 = 252

A mathematician, by the way, would not be content to state that 1015 ≤ 252. He or she would insist on taking the common logarithm of both sides: 15 ≤ log10 252

[Coda.] Prove that there is no guarantee that a double can always hold 16 significant decimal digits. In other words, prove that the distance between consecutive double values is greater than the distance between consecutive 16-sigdig values. Our mathematician friend would explain this by saying log10 252 < 16

For some calculations Celestia uses a C language “single precision floating point”single precision floating point number, known familiarly as a float. Examples include the atmosphereCloudSpeed, albedo, and radius fields in the info tables in infoTable. On my machine, and probably on yours, the parameters in float.h for this data type are the following.

#define FLT_RADIX 2 /* base of exponent */ #define FLT_MIN_EXP (-125) #define FLT_MAX_EXP (+128) #define FLT_MANT_DIG 24 /* # of base FLT_RADIX digits in numerator */

Prove that a float can hold at least 6 significant decimal digits, but not always 7. Confirm this by checking the macro FLT_DIGFLT_DIG (macro) in float.h.

#define FLT_DIG 6

Hint:

6 ≤ log10 223 < 7

We will therefore format a float with string.format using the %g format (which means %.6g) in floatFormat.

Denormalized Numbersdenormalized (double)

We saw in doubleArithmetic that the smallest positive normalized doubledouble precision floating point is 252 253 · 2–1021 = 2–1022 ≈ 2.22507385850720 · 10–308 Even smaller positive values can be stored in a denormalized or subnormal doublesubnormal (double). On my machine, and probably also on yours, a denormalized double has the form ± numerator 253 · 2–1021 The numerator of a denormalized double is an integer in the range 1 to 252 − 1 inclusive. The exponent is fixed at –1021. Thus the smallest positive denormalized double value is 1 253 · 2–1021 = 2–1074 ≈ 4.94065645841247 · 10–324 which is 252 ≈ 4.5 · 1015 times smaller than the smallest positive normalized double. These 15 orders of magnitude will postpone the inevitable day when underflow occurs.

--smallest positive numbers local normalized = 2 ^ (-1021 - 1) local denormalized = 2 ^ (-1021 - 53) local s = string.format( "normalized = %.15g\n" .. "denormalized = %.15g\n" .. "denormalized / 2 = %.15g", normalized, denormalized, --smallest positive value denormalized / 2) --underflow, value is zero normalized = 2.2250738585072e-308 denormalized = 4.94065645841247e-324 denormalized / 2 = 0

We saw that a normalized double guarantees 15 significant decimal digitssignificant digitsof precision for any number within its range. Let’s prove that a denormalized double offers no such guarantee. As the numbers get smaller, they suffer a gradual loss of precision.

The distance between consecutive denormalized doubles is 2–1074. Given a positive number x, the distance between the consecutive 15-sigdig numbers closest to x is

x 1015

If we choose x so that x 1015 < 2–1074 the 15-sigdig numbers will be closer together than the denormalized doubles. For example, if x 1015 < 2–1074 x 1014 a denormalized double will be able to hold 14 significant digits of x but not necessarily 15. Solving the above inequality for x, we get 1014 · 2–1074x < 1015 · 2–1074 And indeed the following x is correct to only 14 digits.

--[[ The last digit (the "9") will not be stored in x. The smallest exponent we can write in Celx is e-307. We have to write e-310 as e-307 * 1e-3. ]] local x = 4.94065645841249e-307 * 1e-3 local s = string.format( "10^14 * 2^-1074 = %.15g\n" .. "10^15 * 2^-1074 = %.15g\n" .. "x = %.15g", 10^14 * 2^-1074, 10^15 * 2^-1074, x) 10^14 * 2^-1074 = 4.94065645841247e-310 10^15 * 2^-1074 = 4.94065645841247e-309 x = 4.94065645841247e-310

Catastrophic Cancellationcatastrophic cancellation

The following value for cos θ is correct to 15 significant digits, and we expect no less. But the value for 1 − cos θ is correct to only 6 significant digits. The next 9 digits are garbageprecision, loss of. What happened?

--[[
Catastrophic cancellation:
cos(theta) is close to 1 for a very small angle theta.
]]
require("std")

local function main()
	local observer = celstia:sane()
	observer:setposition(std.position)
	local theta = math.rad(1 / (60 * 60))	--one second of arc

	local s = string.format(
		   "1              = %.15f\n" --15 dig to right of decimal point
		.. "cos(theta)     = %.15f\n"
		.. "1 - cos(theta) = %.25f computed\n"
		.. "1 - cos(theta) = %.25f correct value (to 15 sig digits)",

		1,
		math.cos(theta),
		1 - math.cos(theta),
		.0000000000117522152695259)

	celestia:print(s, 60)
	wait()
end

main()
1 = 1.000000000000000 cos(theta) = 0.999999999988248 1 - cos(theta) = 0.0000000000117522658271696 computed 1 - cos(theta) = 0.0000000000117522152695259 correct value (to 15 sig digits)

The problem is that 1 and cos θ are very close together, differing only in their rightmost five digits. Beyond those digits, the difference is mostly garbage.

Is our angle of 1″ too small to bother with realistically? Not at all. Astronomers regularly deal in milliarcseconds when studying the angular diameter and exoplanets of a star. See the parallax example in parallax.

So how could we compute 1 − cos θ? Let’s find another expression that has the same value, but without having to subtract two very close numbers.

1 − cos θ = (1 − cos θ) · (1 + cos θ) 1 + cos θ = 1 – cos2 θ 1 + cos θ = sin2 θ 1 + cos θ

This expression comes out correctly to 15 significant digits.

local s = string.format("1 - cos(theta) = %.25f", math.sin(theta)^2 / (1 + math.cos(theta))) 1 - cos(theta) = 0.0000000000117522152695259

Here’s another example where we subtract two very close numbers. Let’s solve the following equation for x, assuming a ≠ 0.

ax2 + bx + c = 0

The quadratic formulaquadratic formula gives us two roots.

x1 = b + b2 − 4ac 2a ,   x2 = bb2 − 4ac 2a

If 4ac is close to zero, then b will be close to b2 − 4ac and catastrophic cancellation will result when we compute x1.

local a = 1 local b = 1 local c = .0000001 --or 1e-7 local r = math.sqrt(b * b - 4 * a * c) local x1 = (-b + r) / (2 * a) local x2 = (-b - r) / (2 * a) local s = string.format( "-b = %.15f\n" .. "r = % .15f\n" --space in place of negative sign .. "-b + r = %.21f computed\n" .. "-b + r = %.21f correct value (to 15 significant digits)\n" .. "x1 = %.21f computed\n" .. "x1 = %.21f correct value (to 15 significant digits)\n" .. "x2 = %.15f computed\n" .. "x2 = %.15f correct value (to 15 significant digits)\n", -b, r, -b + r, -.000000200000020000004, x1, -.000000100000010000002, x2, -.999999899999990)

Our value for x2 is correct to 15 significant digits, but b + r and x1 are correct to only 7.

-b = -1.000000000000000 r = 0.999999799999980 -b + r = -0.000000200000019989766 computed -b + r = -0.000000200000020000004 correct value (to 15 significant digits) x1 = -0.000000100000009994883 computed x1 = -0.000000100000010000002 correct value (to 15 significant digits) x2 = -0.999999899999990 computed x2 = -0.999999899999990 correct value (to 15 significant digits)

So how could we compute x1? Let’s find another expression that has the same value, but without having to subtract two very close numbers. x1x2 = b + b2 − 4ac 2a · bb2 − 4ac 2a  =  b2 − (b2 − 4ac) 4a2  =  4ac 4a2  =  c a Therefore x1 = c ax2 Will this give us x1 to 15 decimal places?

Fixed Pointfixed point numberArithmetic

In one special case, Celx uses a different numeric format to deliver a much higher level of precision. An object of class PositionPosition (class) (position) holds the x, y, z coördinates of a position in the Celestia universe. Each coördinate is stored in the format n · 2–64 where n is an integer ranging from –2127 = –170,141,183,460,469,231,731,687,303,715,884,105,728 ≈ –1.70141183460469 · 1038 to 2127 − 1 = 170,141,183,460,469,231,731,687,303,715,884,105,727 ≈ 1.70141183460469 · 1038 inclusive. Since the exponent is fixed at –64, this format is called fixed point as opposed to the floating point in doubleArithmetic.

The largest fixed point number has the largest n: (2127 − 1) · 2–64 = 263 − 2–64 = 9,223,372,036,854,775,808 – 1 18,446,744,073,709,551,616

= 9,223,372,036,854,775,807.9999999999999999999457898913757247782996273599565029144287109375
The smallest positive fixed point number has the smallest positive n: 1 · 2–64 = 1 18,446,744,073,709,551,616

= .0000000000000000000542101086242752217003726400434970855712890625

≈ 5.42101086242752 · 10–20
And the closest fixed point number to ⅓ has 19 threes before it breaks up into garbage: 6,148,914,691,236,517,205 · 2–64 = 6,148,914,691,236,517,205 18,446,744,073,709,551,616

= 0.3333333333333333333152632971252415927665424533188343048095703125
That’s three orders of magnitude—a thousand times—more precise than the double value of ⅓, which had only 16 threes in doubleArithmetic.

How much precision do we get from a fixed point number in the Celestia universe? The unit of distance is the microlightyear, equal to 9,460,730.4725808 kilometers (lightyear). The distance in kilometers between positions represented by consecutive fixed point values is therefore 1 · 2–64 · 9,460,730.4725808 = 9,460,730.4725808 18,446,744,073,709,551,616 ≈ 5.12867226583596 · 10–13 That’s 5.12867226583596 · 10–10 meters, or a little more than half a nanometernanometer. (One nanometer equals 10–9 meters.)

How many lightyears does the biggest fixed point number represent? Is it adequate for the radius of the observable universeobservable universe (currently 46 billion lightyears)?

Units of Measurement

Celestia’s units range from pixels to parsecs. Pixels belong to real space; parsecs to simulation space. There are also units of angular distance, mass, time, temperature, and flux.

Units of Linear Distance

Lightyears and Microlightyears

The speed of lightlight, speed of is 299,792,458 meters per second. The number of meters in a lightyearlightyear is therefore 299,792,458 · 60 · 60 · 24 · 365.25 = 9,460,730,472,580,800 which works out to 9,460,730,472,580.8 kilometers or approximately .306601393785551 parsecs (parsec). When the number of kilometers is stored as a single precision floating point number (doubleArithmetic), it is rounded to 9,460,730,822,656. An example is the default value of Object:radius for a deep sky object.

The position of an observer or a celestial object is measured in microlightyearsmicrolightyear. A microlightyear is a millionth of a lightyear, a mere 9,460,730.4725808 kilometers. This number is stored in the predefined global variable KM_PER_MICROLYKM_PER_MICROLY (Celx variable), uppercase with underscores.

local s = string.format("KM_PER_MICROLY = %.15g", KM_PER_MICROLY) celestia:print(s, std.duration) wait(std.duration) KM_PER_MICROLY = 9460730.4725808

Older versions of Celestia thought a lightyear was 9,466,411,842,000 kilometers, a value that still lurks in the source code in more than one place. Verify that it resulted from swapping two adjacent digits in the speed of light in kilometers per second.

Astronomical Unitsastronomical unit

The average distance from the Sun to the Earth is called an astronomical unit. Celestia uses the value 149,597,870.7 kilometers in version/src/celengine/astro.h, available in the standard library as std.kmPerAustd.kmPerAu. It is equivalent to roughly 16 microlightyears (std.microlyPerAustd.microlyPerAu). Astronomical units are used in the .ssc files for the orbits of planets, asteroids, and comets, but not for the satellites of the planets.

Parsecsparsec

A parsec is the distance from the Sun to an object whose annual parallaxparallax is 1″ (one second) of arc. The angle is exaggerated in the following diagram. In terms of astronomical units, 1 parsec = 1 sinsine (trig function) 1″ au ≈ 206,264.806247905 au This works out to approximately 3.26156377790433 lightyears (std.lyPerPcstd.lyPerPc) or 30,856,775,821,885.3 kilometers (std.kmPerPcstd.kmPerPc).

To approximate a parsec without computing sine, recall that the graph of sine goes through the origin at a 45° angle.

Thus for a tiny angle of θ radians we have sin θθ Our angle of 1″ is equal to 2π 360 · 60 · 60 radians so sin 1″ = sin ( 2π 360 · 60 · 60 radians) ≈ 2π 360 · 60 · 60 Therefore 1 parsec ≈ 360 · 60 · 60 2π au ≈ 206,264.806272689 au which is correct to 10 significant digits.

Ancient versions of Celestia had an origin one milliparsec (about 206 astronomical units) from the Sun; see universalFrame. The milliparsec still shows up when we press the Follow Earth button in a Celestia compiled --with-kdeKDE (K Desktop Environment).

Kilometerskilometer

Intraplanetary distances are in kilometers:

local earth = celestia:find("Sol/Earth") local radius = earth:radius() --in kilometers local info = earth:getinfo() --a table of fields radius = info.radius --in kilometers, the same number local atmosphereHeight = info.atmosphereHeight --in kilometers local atmosphereCloudHeight = info.atmosphereCloudHeight --in kilometers

The radius of a spacecraft is in kilometers. Multiply it by 1000 to convert to meters.

local cassini = celestia:find("Sol/Cassini") local s = string.format("Cassini radius = %g meters", 1000 * cassini:radius()) Cassini radius = 11 meters

The radius of a deep sky object (a galaxy, globular cluster, open cluster, or nebula) is available in kilometers or lightyears:

local dso = celestia:find("Milky Way") local kilometers = dso:radius() local lightyears = dso:getinfo().radius --not microlightyears

The distance between two objects is available in kilometers or microlightyears:

local earth = celestia:find("Sol/Earth") local earthPosition = earth:getposition() --position of center local moon = celestia:find("Sol/Earth/Moon") local moonPosition = moon:getposition() --distance in kilometers local kilometers = earthPosition:distanceto(moonPosition) --a vector pointing from center of Earth to center of Moon local vector = moonPosition - earthPosition --the same distance, but in microlightyears local microlightyears = vector:length()

Kilometers are used for the distance argument of Observer:gotolonglat and Observer:gotodistance.

The kilometer was originally defined as 1/40000 of the polar circumferenceEarth, circumference of the Earth.

local earth = celestia:find("Sol/Earth") local r = earth:radius() --in kilometers local circumference = 2 * math.pi * r local s = string.format("circumference = %.15g km", circumference) circumference = 40075.0363941636 km

Can we make the error smaller? The Earth is actually shaped like an oblate spheroidoblate spheroid (shape of Earth), bulging at the equator. In cross section it is an ellipseellipse, as cross section of oblate spheroid whose semi-majorsemi-major axis (of ellipse) and semi-minorsemi-minor axis (of ellipse) axes are the following a and b. There is—surprisingly—no simple formula for the circumference of an ellipse, but the following for loop gives us a good approximation.

local earth = celestia:find("Sol/Earth") local oblateness = .0034 --from comment in data/solarsys.ssc local a = earth:radius() --equatorial radius, in kilometers local b = a * (1 - oblateness) --polar radius, in kilometers local e = math.sqrt(1 - (b / a)^2) --eccentricity local product = 1 local sum = 1 for i = 2, 10, 2 do --Count by twos. The terms get smaller rapidly. product = product * (i - 1) / i sum = sum - product^2 * e^i / (i - 1) end local circumference = 2 * math.pi * a * sum local s = string.format("circumference = %.15g km", circumference) celestia:print(s, 60) circumference = 40006.9378358186 km

For the remaining sources of error, see Ken AdlerAdler, Ken, The Measure of All ThingsMeasure of All Things, The (Adler): The Seven-Year Odyssey and Hidden Error That Transformed the World. To land a spacecraft on the surface of an oblate spheroid, see geodetic.

Pixelspixels in Real Space

Measurements in pixels belong to the domain of the real space of the user, not the simulation space of the observer (universalFrame). Celestia assumes that the screen has 96 pixels per inch (std.pixelsPerInstd.pixelsPerIn). The dimensions of the window are in pixels; see Celestia:getscreendimension (celestiaPrint and windowDimensions) and the resize Lua hook (luaHookFunctions). Together with the user’s default distance of 400 millimeters from the screen, these dimensions determine the user’s field of view in real space (std.getverticalfov and std.gethorizontalfov). With the observer’s level of magnification, we can then determine the observer’s field of view in simulation space (Observer:getverticalfov and Observer:gethorizontalfov). One inch, by the way, equals 25.4 millimeters (std.mmPerInstd.mmPerIn). One mile equals 5280 feet (std.ftPerMilestd.ftPerMile).

Fonts, strings, and marks are measured in pixels, not pointspoint (font size); see Celestia:gettextwidth, Font:getwidth, Font:getheight, and Object:mark.

Units of Angular Distance

Degrees, Minutes, and Seconds of Arc

One full turn is divided into 360 degreesdegree (of arc) of arc. Each degree is divided into 60 minutesminute (of arc), and each minute into 60 secondssecond (of arc). Thus, 1° = 60′ = 3600″ A milliarcsecond is a thousandth of second. Celestia uses degrees for the field of view in a cel: URL; see Celestia:geturl.

Hours, Minutes, and Seconds of Right Ascensionright ascension

The angle through which the Earth rotates during a given period of time may be described as 0 to 360 degrees, or as 0 to 24 hourshour (of right ascension) of right ascension. In this system, 24h = 360°
  1h =   15°
Dividing both sides by 60, we see that a minuteminute (of right ascension) of right ascension is 15 times bigger than a minute of arc: 1m = 15′ Another division by 60 shows that a secondsecond (of right ascension) of right ascension is 15 times bigger than a second of arc. 1s = 15″

We also give another interpretation to right ascension, thinking of it as the angle through which the Universe rotates around a stationary Earth. See j2000Equatorial.

Radiansradian

An angle can also be measured in radians. One full turn is divided into 2π radians, so 2π radians = 360°

We use 2π because it is the circumference of the unit circle, whose radius is 1. The size in radians of an angle is equal to the length of the portion of the circumference that is subtended by the angle. For example, an angle of 2π radians goes all the way around the circumference.

ππ (mathematical constant) is approximately 3.14159265358979 and is available as math.pimath.pi. local circumference = math.pi * diameter

Here are the conversion factors, to 15 significant digits.

1 radian  =  180 π degrees  ≈  57.2957795130823°  ≈  57° 17.7467707849393′  ≈  57° 17′ 44.8062470963552″

1 degree  =  π 180 radians  ≈  .0174532925199433 radians

Lua provides two conversion functions.

local radians = math.radmath.rad(degrees) --Convert degrees to radians. local degrees = math.degmath.deg(radians) --Convert radians to degrees.

All Lua trig functions are in radians.

local s = math.sinmath.sin(radians) --Argument of sin is in radians. local radians = math.asinmath.asin(s) --Return value of arcsin is in radians.

Celx functions and fields are in radians too.

--vertical field of view of window local observer = celestia:getobserver() local radians = observer:getfov() --Return value is in radians. observer:setfov(radians) --Argument is in radians. local object = celestia:find("Sol/Earth") local info = object:getinfo() local radiansPerDay = info.atmosphereCloudSpeedatmosphereCloudSpeed (info table field) local s = "" if radiansPerDay ~= nil then s = string.format("Cloud speed = %g degrees per day.", math.deg(radiansPerDay)) if radiansPerDay ~= 0 then s = s .. string.format( "\nThe clouds take %g day(s) to circle %s.", 2 * math.pi / radiansPerDay, object:name()) end end Cloud speed = 65 degrees per day. The clouds take 5.53846 day(s) to circle Earth.

The Celx Standard Library functions (standard) are in radians.

--[[ latitude is in radians: positive for north, negative for south. lat is a table containing four fields, direction is a table containing two fields. ]] local lat = std.tolat(latitude) local direction = {[1] = "North", [0] = "", [-1] = "South"} local s = string.format( "Latitude %d degrees %d minutes %.15g seconds %s", lat.degrees, lat.minutes, lat.seconds, direction[lat.signum]) --longitude is in radians: positive for east, negative for west. --long is a table containing four fields. local long = std.tolong(longitude) local direction = {[1] = "East", [0] = "", [-1] = "West"} local s = string.format( "Longitude %d degrees %d minutes %.15g seconds %s", long.degrees, long.minutes, long.seconds, direction[long.signum]) --rightAscension is in radians, e.g. pi radians = 12 hours. --ra is a table containing three fields. local ra = std.tora(rightAscension) local s = string.format( "Right Ascension %d hours %d minutes %.15g seconds", ra.hours, ra.minutes, ra.seconds) --declination is in radians, e.g. pi/2 radians = 90 degrees. --dec is a table containing four fields. local dec = std.todec(declination) local s = string.format( "Declination %s%d degrees %d minutes %.15g seconds", std.sign(dec.signum), dec.degrees, dec.minutes, dec.seconds)

Units of Time

See time for the difference between simulation time and real time. Simulation time is measured in Earth days, starting with the methods Celestia:gettime and Celestia:settime. This type of day is the mean solar daymean solar day of 24 hours, not the sidereal daysidereal day of 23 hours, 56 minutes, 4 seconds.

Intervals of real time are measured in seconds, starting with the return value of Celestia:getscripttime. A second is 1 60 · 60 · 24  =  1 86,400 of a day. The method Celestia:getsystemtime returns the current real time, not the length of an interval, so its return value is in days.

The dimensionlessdimensionless quantity ratio between simulation time and real time is manipulated with Celestia:gettimescale and Celestia:settimescale. See settime.

Units of Temperature

Temperature is measured in degrees KelvinKelvin (unit of temperature). To convert Kelvin to CelsiusCelsius (unit of temperature), subtract 273.15. To convert Celsius to Fahrenheit, multiply by 212 − 32 100 − 0  =  9 5 and add 32.

The temperature of a star can be read from the temperature field of the star’s info table. See infoTable. The temperature of a non-luminous body can be displayed when Celestia is set to verbose mode. On Macintosh,
Celestia → Preferences… → General → Info Display
On Microsoft Windows,
Render → View Options… → Information Text
Then select the body as in keystroke.

Miscellaneous Units

The luminosity field of the info table (infoTable) of a star is measured in multiples of the Sun’s luminositySun, luminosity of. This value is 3.8462 · 1026 watts in version/src/celengine/astro.cpp.

The mass field of the info table of an extrasolar planetextrasolar planet is measured in multiples of the mass of the EarthEarth, mass of. This value is 5.976 · 1024 kilograms in version/src/celengine/astro.cpp. JupiterJupiter, mass of, to which extrasolar planets are often compared, is approximately 318 times as massive as the Earth.

The atmosphereCloudSpeed field of the info table of a planet or moon is measured in radians per day. (The corresponding field in a .ssc file is in degrees per day.) The argument of Observer:setspeed is in microlightyears per second of real time. The fifth argument of Celestia:print is in emsem (unit of width) (the width of an uppercase letter M).

Some measurements are dimensionlessdimensionless quantity because they are the ratios of two measurements with the same units. These include the albedo and oblateness fields of the info table, the timescale between the simulation time and the real time, the Eccentricity field of an EllipticalOrbit in data/solarsys.ssc, the magnification passed to Observer:setmagnification in Magnification, and the Rayleigh, absorption, and Mie coefficients passed to Object:setatmosphere. A mathematical purist would also say that an angle in radiansradian is dimensionless.

Some measurements are downright subjective. Consider the importance field of the info table of a location: is the second most important world capital really SeoulSeoul, South Korea?

Formattingformatting Numbers and Strings

Formatting a value means rendering it as a humanly-readable string of characters. For example, an integer (whole number) can be formatted in decimal or hexadecimal, and any number can be formatted in scientific notation or in the plain old notation. a string can be left or right justified. A date or time can be formatted as in tdb. For colors, see integerFormatting.

Numeric Formatting

This section assume that we have a normal Celx number. To print a fixed point number in a Position object, see bits128.

The Default Format

By default, a number is converted into a string with 14 significant decimal digits. The conversion can be performed in two ways: explicitly, by the Lua function tostringtostring (Lua function); or implicitly, by concatenation to an existing string. The existing string can even be the null string "".

--[[
Demonstrate the default format (14 significant digits)
in which a number is rendered as a string.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	local n = 1234567890.12345	--15 significant decimal digits
	local explicit = tostring(n)	--explicit conversion to string
	local implicit = n .. ""	--implicit conversion to string

	celestia:print(explicit .. "\n" .. implicit, 60)
	wait()
end

main()
1234567890.1235 1234567890.1235

These 14 digits are one less than the 15 digits guaranteed by a double in doubleArithmetic. The culprit is the “number format” macro in the header file luaversion/src/luaconf.hluaconf.h (header file).

/* Excerpt from luaconf.h. */ #define LUA_NUMBER_FMTLUA_NUMBER_FMT (macro) "%.14g"

Explicit Formatting with string.format

The Lua function string.formatstring.format gives us control over how a number is formatted. For example, it can wring out all 15 significant digits of a double, or round the double to fewer decimal places. The function accepts the same % formats% format (string.format) as the C function printfprintf (C function).

Integer Formatting

Let’s start with the integers. The format %d prints in decimal. An optional number before the %d specifies the minimum number of characters to be output; a narrower number will be padded with leading blanks or a zeroes.

local bond = 7 local s = string.format("%d", bond) --"7", a single character s = string.format("%3d", bond) --" 7" with 2 leading blanks s = string.format("%03d", bond) --"007" with 2 leading zeroes. celestia:print(s, 10) --Assume hour, minute, second are integers. --Humans like to see them as two-digit numbers, e.g., "01:02:03". local s = string.format("%02d:%02d:%02d", hour, minute, second)

The format %d prints a negative number with a minus sign, but a positive number with no plus sign. To print every value with an explicit sign, use %+d. (Zero will have a plus sign.)

local i = 10 local s = string.format("%d", i) --"10" s = string.format("%+d", i) --leading plus: "+10"

Print an integer in base 10 with %d, in base 8 with %o, and in base 16 with %X or %x (upper or lowercase).

local i = 3735928559 local s = string.format("%X", i) --"DEADBEEF" local A = 65 --ASCII and Unicode code numbers of uppercase A local s = string.format("%04X", A) --"0041" --Compose the colorcolor, compose in hex for Object:mark. local red = 0 local green = 128 local blue = 255 local s = string.format("#%02X%02X%02X", red, green, blue) --"#0080FF" celestialObject:mark(s, "plus", 10) local i = 3735928559 --Convert i to octal. local s = string.format("%o", i) --A table containing eight fields. --The keys are strings in octal, the values are strings in binary. local octalToBinary = { ["0"] = "000", ["1"] = "001", ["2"] = "010", ["3"] = "011", ["4"] = "100", ["5"] = "101", ["6"] = "110", ["7"] = "111" } --Convert s from octal to binarybinary, convert from octal, one octal digit at a time. s = s:gsubstring.gsub("%d", octalToBinary) --"11011110101011011011111011101111"

The format %u stands for “unsigned”unsigned number. It prints an integer in base 10, adding 231 = 2,147,483,648 if necessary to make the integer nonnegative. See inputFile.

The %d cannot be used for an integer greater than or equal to 231 = 2,147,483,648 or less than –231 = –2,147,483,648. An integer in the range 231 to 232 − 1 = 4,294,967,296 inclusive can be formatted with %u, %o, %X, or %x. An integer greater than or equal to 231 can a be formatted as a float (floatFormat) with no digits to the right of the decimal point.

--Biggest integer that can be formatted with %d. local i = 2^31 - 1 local s = string.format("%d", i) --"2147483647" --Biggest integer that can be formatted with %u. local i = 2^32 - 1 local s = string.format("%u", i) --"4294967295" --Can print any integer in range -2^53 to 2^53 inclusive. local i = 2^32 local s = string.format("%.0f", i) --"4294967296"

Verify that the above example can also be written as follows. The parentheses around the string are required. Which way is easier to understand?

local i = 2^32 local s = ("%.0f"):format(i) --"4294967296"
Floating Point Formatting

The format %f prints a number with a fraction, by default with six digits to the right of the decimal point. A number before the %f specifies the minimum number of characters to be output. The number after the dot specifies the number of digits to the right of the decimal point.

local x = 123.123456789 local s = string.format("%f", x) --"123.123457" s = string.format("%12f", x) --" 123.123457" with 2 leading blanks s = string.format("%.9f", x) --"123.123456789" s = string.format("%15.9f", x) --" 123.123456789" with 2 leading bla

The format %e delivers scientific notation, by default with six digits to the right of the decimal point.

local avogadroAvogadro’s number = 6.02214129e23 --means 6.02214129 * 10^23 local s = string.format("%f", avogadro) --"602214128999999968641024.000000" s = string.format("%e", avogadro) --"6.022141e+23" s = string.format("%.8e", avogadro) --"6.02214129e+23"

The format %g behaves like %e or %f, depending on the magnitude of the value. The number after the dot specifies the total number of significant digits to be printed. To print the entire value, with no advance knowledge of its magnitude, your best bet is %.15g.

local s = string.format("%.15g", 1/3) --"0.333333333333333" s = string.format("%.15g", math.pi) --"3.14159265358979" s = string.format("%.15g", KM_PER_MICROLY) --"9460730.4725808" s = string.format("%.15g", std.c) --"299792.458" speed of light in km/s

String Formatting

Format a string of characters with %s.

local name = "Orionis" local s = string.format("%s", name) --"Orionis" s = string.format("%9s", name) --" Orionis" with 2 leading blanks s = string.format("%-9s", name) --"Orionis " with 2 trailing blanks s = string.format("%.3s", name) --"Ori" s = string.format("%5.3s", name) --" Ori" with 2 leading blanks s = string.format("%-5.3s", name) --"Ori " with 2 trailing blanks

To add quotesquotes around string to the string, see the %q in standard.

Special Charactersspecial characters

Astronomical writing is full of special characters, starting with the symbols for degrees, minutes, and seconds: 1° 2′ 3″. Although it might be possible to write these characters in the source code of a Celx program, such usage is platform dependent. A portable way to represent a special character is via its UnicodeUnicode character number in the code charts at www.unicode.org. For example, the code number of the degree symbol ° is 176 in decimal and 00B0 in hexadecimal. To display the code number of every character in a font, see inputFile

A Unicode number is written as a four-digit hexadecimal number, with a leading 0x (zero lowercase X) in Celx to indicate its base. To print the corresponding character, the number must be broken down into a series of 8-bit bytes in UTF-8UTF-8 (character encoding) format, and then pasted back together with the Lua function string.charstring.char. For example, the following 0x03B1 (the code number of the lowercase Greek letter α) must be broken into 0xCE and 0xB1 before it can be reconstituted as a one-character string.

An easy way to convert a Unicode number into a one-character string is with the function std.utf8std.utf8. Even easier, the table std.charstd.char contains fields for the most common special characters.

--[[
Print a special character.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	--Three ways to print Greek lowercase alpha (Unicode 03B1).

	local s = string.char(0xCE, 0xB1) .. "\n"	--hard
		.. std.utf8(0x03B1) .. "\n"		--easier
		.. std.char.alpha .. "\n"		--easiest

		.. std.char.alpha .. " Orionis (Betelgeuse), Declination "
		..  7 .. std.char.degree .. " "
		.. 24 .. std.char.minute .. " "
		.. 25 .. std.char.second .. " North"

	local duration = 60
	celestia:print(s, duration)
	wait(duration)
end

main()
α α α α Orionis (Betelgeuse), Declination 7° 24′ 25″ North

The string can also be composed with string:format (stringFormat).

local s = string.format( "%s %.3s (%s), Declination %d%s %d%s %d%s North", std.char.alpha, "Orionis", "Betelgeuse", 7, std.char.degree, 24, std.char.minute, 25, std.char.second) α Ori (Betelgeuse), Declination 7° 24′ 25″ North

Data Structures: Objects, Tables, and Databases

Lua Objects, Celx Objects, and Celestial Objects

A Celx variable can refer to an object, as we saw in objectsVsVariables. These objects fall into two groups. A Lua objectLua object is inherited from the language Lua and represents a computer science concept such as a string or an array. A Celx objectCelx object is unique to Celx and represents a mathematical or astronomical concept such as a vector or a planet. To tell them apart, call the Lua function typetype (Lua function).

--[[ v is the variable to be examined. Also try local v = celestia:newposition(0, 0, 1) local v = "hello" local v = 10 ]] local v = celestia local t = type(v) local s = nil if t == "string" or t == "table" or t == "thread" then s = "v refers to a Lua object." elseif t == "userdata" then s = "v refers to a Celx object (or to a file handle)." else s = "v does not refer to an object." end celestia:print(s, 10) v refers to a Celx object (or to a file handle).

A Celx object belongs to one of the twelve classesclass of Celx object in additions. Call the Lua function tostringtostring (Lua function) to find out which one.

local observer = celestia:getobserver() local position = celestia:newposition(0, 0, 1) local celestialObject = celestia:find("Sol/Mars") local s = string.format("These objects belong to classes\n" .. "%s\n%s\n%s\n%s", tostring(celestia), tostring(observer), tostring(position), tostring(celestialObject)) celestia:print(s, 60)

The name of the class begins with an uppercase letter. We will ignore the [square brackets].

These objects belong to classes [Celestia] [Observer] [Position] [Object]

A Celx object belonging to class Object (i.e., class "[Object]") represents a celestial object such as a planet, spacecraft, or even a city or crater. To get a celestial object from one of Celestia’s databases, call the method Celestia:findCelestia:find with an argument such as one of the following. The argument is case-insensitive, but any chunk of whitespace in the argument must consist of exactly one space.

"Sol"
"Sol/slash (in name of celestial object)Earth"     
(a planet in our solar system)
"Sol/Earth/Moon"
"Sol/Earth/Moon/Mare Tranquillitatis"

"Pollux"
"Pollux/b"      
(a planet in another solar system)

Celestia:find returns a Celx object of class Object. The method Object:name returns the rightmost segment of the object’s name (e.g., the "Moon" of "/Sol/Earth/Moon"), excluding the slashes and the characters to the left of any slash. The method Object:typeObject:type returns a string giving the object’s astronomical classification, such as "planet", "star", or "comet".

If Celestia:find cannot find the celestial object, its behavior will depend on whether the standard library has been required. If the library has been required, Celestia:find will call the Lua function errorerror (Lua function) with the message “Celestia:find could not find "<name>"”. And as we know, error never returns. If the library has not been required, Celestia:find will return a dummy object whose name and type are the strings "?" and "null" respectively. (If the library has been required, and a dummy object is desired as the result of an unsuccessful search, call Celestia:oldfind.)

Every celestial object has an “info table” of information (infoTable) The object’s name and type are also present as fields in the info table. The dummy object has an info table with no name field, and a type field whose value is the string "null".

By default, a goto takes five seconds of real time, available as the constant std.duration. To ensure that nothing else happens while the goto is in progress, we can wait (wait) for the same number of seconds.

--[[
Find a celestial object in Celestia's database.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	local name = "Sol/Mars"
	local object = celestia:find(name)

	--This is the check that Celestia:find does for us automatically,
	--since we required std.
	--[[
	if type(object) ~= "userdata"
		or tostring(object) ~= "[Object]"
		or object:type() == "null"
		or object:name() == "?" then
		error("Celestia:find could not find \"" .. name .. "\"")
	end
	]]

	celestia:select(object)			--display distance, radius, etc.

	local s = string.format(
		"type(object) = %s\n"
		.. "tostring(object) = %s\n"
		.. "object:name() = %s\n"
		.. "object:type() = %s\n",
		type(object), tostring(object),
		object:name(), object:type())

	celestia:print(s, 60, -1, 1, 1, -6)	--below selected object info
	observer:goto(object)	--go to a point 5 radii from the object's center
	wait(std.duration)	--the number of seconds that the goto takes
end

main()
type(object) = userdata tostring(object) = [Object] object:name() = Mars object:type() = planet

We have just seen two types: the Lua function type and the type method of class Object. There is also a Lua function named typeio.type that checks if a file is currently open (inputFile), and a field named type in a celestial object’s info table (infoTable) and in the table passed to the method Object:addreferencemark.

The prefix "Sol/" in the above "Sol/Mars" was unnecessary, since Sol is the star of the solar system closest to the observer. Verify that the "Sol/" becomes necessary if the observer has first gone to Betelgeuse: --Go to a position 100 Betelgeuse radii from center of Betelgeuse. local betelgeuse = celestia:find("Betelgeuse") observer:goto(betelgeuse) wait(std.duration) celestia:find("Mars") --Now find calls the error function.

If we go from the standard position to the Sun, local observer = celestia:sane() observer:setposition(std.position) local object = celestia:find("Sol") observer:goto(object) wait(std.duration) the Observer:goto makes a terrifying plunge towards the solar surface. To diagnose this, we have to know that every type of celestial object has a preferred distancepreferred distance. For a planet, the distance is five times the radius. For a star, it’s 100 times the radius, as in the previous exercise. If we are already closer than the preferred distance, a goto takes us 10 times closer than our current distance. (Warning. The preferred distance for a black hole is 100 times the radius.)

To go to an explicit distance from the object, call the method Observer:gotolonglatObserver:gotolonglat. Call math.rad to convert the longitude and latitude to radians.

observer:gotolonglat(object, math.rad(0), math.rad(0), 5 * info.radius)

Check that all of the following find the same star. Each chunk of whitespace must be exactly one space.

--Arabic name local name = "Betelgeuse" local name = "betelgeuse" --case insensitive local name = "Betelgeuze" --alternative spelling --Bayer designation: name of constellation in Latin genitive case local name = "Alpha Orionis" local name = "Alpha Ori" local name = "ALF Ori" local name = std.char.alpha .. " Orionis" --Flamsteed designation local name = "58 Orionis" local name = "58 Ori" --catalog numbers local name = "HIP 27989" --Hipparcos local name = "HD 39801" --Henry Draper local name = "SAO 113271" --Smithsonian Astrophysical Observatory

The method Object:nameObject:name returns only one of the names of a star. It prefers to return a name in Arabic, Latin, Greek, or even English (try "The Garnet Star"). Otherwise it returns the BayerBayer designation (of star) or Flamsteed designationFlamsteed designation (of star). As a last resort, it returns one of the catalog numbers: HipparcosHipparcos catalog, Henry DraperHenry Draper catalog, or Smithsonian Astrophysical ObservatorySmithsonian Astrophysical Observatory catalog. To get all the names of a star, see inputFile.

An Array of Strings

Lua’s only data structure is the tabletable (Lua), an associative array or hash of key/value pairskey/value pair. We look up a key in a table to find the corresponding value. The following table is named zodiac. Its keys are not explicitly specified, so they default to increasing consecutive integers starting at 1. A table with these keys is called an arrayarray (Lua table) or a sequencesequence (Lua table); see the Lua documentation for the fine points. The operator ## (highest key operator) returns the highest key in the array, which is the number of key/value pairs. (In Lua 5.2, this operator has replaced the function table.maxn.) The value corresponding to each key in the following table is a string.

There are two ways to loop through an array: with a numeric for loopfor loop, or with a generic for loop and the Lua function ipairsipairs (Lua function). The generic loop calls ipairs once. The return value is another function, called an iteratoriterator. We never see this iterator, but it is called over and over by the loop. Each call returns the next key/value pair from the array. When a call returns nilnil (Lua value), the loop terminates.

The first three arguments of Celestia:setconstellationcolorCelestia:setconstellationcolor are the red, green, and blue components of the color, in the range 0 to 1. We’ll use the rgb values in the Renderer::EclipticColorRenderer::EclipticColor (C++ constant) in version/src/celengine/render.cpp. The optional fourth argument is an array of constellation names.

--[[
Create and print an array of strings.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	local zodiac = {	--Curly braces create a table.
		"Aries",	--Key is 1, value is "Aries".
		"Taurus",	--Key is 2, value is "Taurus".
		"Gemini",
		"Cancer",
		"Leo",
		"Virgo",
		"Libra",
		"Scorpius",
		"Sagittarius",
		"Capricornus",
		"Aquarius",
		"Pisces"	--Key is 12, value is "Pisces".
	}

	assert(type(zodiac) == "table" and #zodiac == 12)

	local s = string.format(
		"The zodiac has %d constellations, from %s to %s:\n\n",
		#zodiac,
		zodiac[1][ ] (subscripting operator),        --Look up first key & get corresponding value.
		zodiac[#zodiac])  --Look up last key & get corresponding value.

	--Two ways to do the same loop.

	--numeric for loop
	for i = 1, #zodiac do
		s = s .. i .. " " .. zodiac[i] .. "\n"
	end

	s = s .. "\n"	--Skip a line between the two lists.

	--generic for loop
	assert(type(ipairs) == "function")
	assert(type(ipairs(zodiac)) == "function") --An iterator is a function.

	for i, value in ipairs(zodiac) do
		s = s .. i .. " " .. value .. "\n"
	end

	celestia:setconstellationcolor(.5, .1, .1, zodiac)	--rgb
	celestia:print(s, 60, -1, 1, 1, -1)	--upper left corner of window
	wait()
end

main()
The zodiac has 12 constellations, from Aries to Pisces: 1 Aries 2 Taurus 3 Gemini 4 Cancer 5 Leo 6 Virgo 7 Libra 8 Scorpius 9 Sagittarius 10 Capricornus 11 Aquarius 12 Pisces 1 Aries 2 Taurus 3 Gemini 4 Cancer 5 Leo 6 Virgo 7 Libra 8 Scorpius 9 Sagittarius 10 Capricornus 11 Aquarius 12 Pisces

Number the constellations from 0 to 11. Since ipairs always starts at 1, we will have to do the counting with a numeric for loop.

local zodiac = { [0] = "Aries", --Key is 0, value is "Aries". "Taurus", --Key is 1, value is "Taurus". "Gemini", --Key is 2, value is "Gemini". --etc. } --#zodiac is no longer the number of pairs. assert(type(zodiac) == "table" and #zodiac == 12 - 1) for i = 0, #zodiac do s = s .. i .. " " .. zodiac[i] .. "\n" end

Loop through the array std.zodiacstd.zodiac in the standard library.

A two-dimensional arrayarray, two-dimensional is implemented as an “array of arrays”, created with nested curly braces. Each column can be a different data type. The fourth argument of Celestia:setconstellationcolor must be an array of strings. In this case it is a very short array, consisting of only one string. local zodiac = { --name r g b {"Aries", 255, 0, 0}, --red {"Taurus", 255, 127, 0}, --orange {"Gemini", 255, 255, 0}, --yellow {"Cancer", 0, 255, 0}, --green {"Leo", 0, 0, 255}, --blue {"Virgo", 75, 0, 130}, --indigo {"Libra", 127, 0, 255}, --violet --etc. } assert(type(zodiac) == "table" and #zodiac == 12) local s = string.format( "The zodiac has %d constellations, from %s to %s.\n" .. "The amount of red in the first constellation is %d.", #zodiac, zodiac[1][1], --Can apply 2 subscripts to a 2-dimensional array. zodiac[#zodiac][1], zodiac[1][2]) for i, value in ipairs(zodiac) do assert(type(value) == "table" and #value == 4) celestia:setconstellationcolor( value[2] / 255, value[3] / 255, value[4] / 255, {value[1]}) --Curly braces create a table. end

A moving groupmoving group (of stars) is a group of stars that move together. Let’s mark the core stars of the Ursa Major Moving GroupUrsa Major Moving Group. The third argument of Object:mark is the size in pixels of the marker symbol. The fourth argument is the alpha level. --An array that contains strings: --the names of the core stars of the Ursa Major Moving Group. local group = { "Alcor", --bound to Mizar "Alioth", --Epsilon Ursae Majoris "Merak", --Beta Ursae Majoris, one of the Pointers "Megrez", --Delta Ursae Majoris, joins bowl to handle "Mizar A", --Zeta Ursae Majoris "Mizar B", "Phecda", --Gamma Ursae Majoris "78 UMa", "37 UMa", "HIP 64532", --Hipparcos catalog number "HIP 61100", "HIP 61946", "HIP 61481" --in Canes Venatici } --[[ Face Megrez in the Big Dipper. Orient the observer so that Megrez is in the center of the window, and the line from Megrez to the north pole of the ecliptic in Draco points straight up towards the top of the window. ]] local megrez = celestia:find("Megrez") local position = megrez:getposition() observer:lookatObserver:lookat(position, std.yaxis) for i, name in ipairs(group) do local star = celestia:find(name) star:mark("green", "diamond", 10, .9, star:name()) end

Let the size of the marker show the distance to the star. local position1 = observer:getposition() local position2 = star:getposition() local distance = position1:distanceto(position2) --in kilometers star:mark("green", "diamond", distance / 7e13, .9, star:name()) Are all the stars at approximately the same distance from us? What about the seven stars that form the Big DipperBig Dipper?

The Virgo ClusterVirgo Cluster (of galaxies) is the cluster of galaxiesgalaxy cluster at the heart of the Virgo SuperclusterVirgo Supercluster. Look at its most prominent member, the giant galaxy M87M87 (galaxy) in VirgoVirgo. Then mark the 16 Messier objectsMessier object that belong to the cluster.

--Zoom in to prevent the labels from stepping on each other. observer:setfov(math.rad(16)) --[[ The keys in this array are numbers. The values are numbers too. The values are the numbers of the Messier objects in the Virgo Cluster. ]] local messier = { 49, --M49, elliptical. 58, --spiral 59, --elliptical 60, --elliptical 61, --spiral 84, --lenticular 85, --lenticular 86, --lenticular 87, --giant elliptical 88, --spiral 89, --elliptical 90, --spiral 91, --barred spiral 98, --spiral 99, --spiral 100 --spiral } for i, m in ipairs(messier) do --one space after the uppercase M local name = string.format("M %d", m) --etc. end

An Array of Celestial Objects

The keys in the next array are still integers, but now the values are celestial objects.

Human beings like to see their numbers right justified and their strings left justified. A positive width such as %2d will right justify, and a negative width such as %-9s will left justify. Of course, the columns will line up only if the font is monospace.

--[[
Print a table of celestial objects: the moons of NeptuneNeptuneNeptune
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	local neptune = celestia:find("Sol/Neptune")
	local children = neptune:getchildren()	--an array of celestial objects
	assert(type(children) == "table")

	local s = ""

	for i, child in ipairs(children) do
		assert(type(child) == "userdata"
			and tostring(child) == "[Object]")

		s = s .. string.format("%2d %-9s %6.1f (%s)\n",
			i, child:name(), child:radius(), child:type())
	end

	celestia:print(s, 60, -1, 1, 1, -1)	--upper left corner of window
	wait()
end

main()

GalateaGalatea (moon of Uranus) is a minor moon even though it’s bigger than LarissaLarissa. See the comment at the start of data/solarsys.sscsolarsys.ssc (file).

1 Larissa 100.0 (moon) 2 Proteus 219.0 (moon) 3 Triton 1353.4 (moon) 4 Nereid 170.0 (moon) 5 Naiad 47.0 (minormoon) 6 Thalassa 54.0 (minormoon) 7 Despina 90.0 (minormoon) 8 Galatea 102.0 (minormoon) 9 Halimede 24.0 (minormoon) 10 Sao 24.0 (minormoon) 11 Laomedeia 24.0 (minormoon) 12 Neso 30.0 (minormoon) 13 Psamathe 14.0 (minormoon)

Recursionrecursion: a Tree of Celestial Objects

So, nat’ralists observe, a flea
Has smaller fleas that on him prey;
And these have smaller still to bite ’em,
And so proceed ad infinitum.
Jonathan SwiftSwift, Jonathan, On Poetry: A RhapsodyOn Poetry: A Rhapsody (Jonathan Swift) (1733)

A recursive data structure contains subparts with the same structure as the whole. An example is the family tree of the Sun, containing trees within trees within trees. The simplest way to traverse a recursive data structure is with a recursive function, one that calls itself.

Let’s write a recursive function that will print the entire tree rooted at the Sun, one object per line. The planets will be indented by one pair of spaces, their satellites by two pairs of spaces, and the satellites of the satellites by three pairs of spaces. The pair of spaces is written as a string literalliteral (a value in double quotes: "  "). To make the literal count as an object with methods, we have to enclose it in parentheses. To create multiple copies of this object, we call the Lua method string.repstring.rep (“replicate”).

Celestia:print will print no more than 210 − 1 = 1023 characters of a string; see celestiaPrint. The string:substring.sub method returns a prefix of this length. To see the entire string, we can print it to the standard output (standardOutput), to an output file (outputFile), or to the Celestia console (console). In the latter case, we must set the LogSizeLogSize (parameter in celestia.cfg) in celestia.cfg to at least 222.

Another recursive function is the gcd (greatest common divisor) in windowDimensions.

--[[
This function displays the tree of celestial objects whose root is the first
argument.  The root is indented by level pairs of blanks.  Its children are
indented by level+1 pairs, its children's children by level+2 pairs, etc.
]]
require("std")

local function tree(root, level)
	assert(type(root) == "userdata" and tostring(root) == "[Object]"
		and type(level) == "number" and level >= 0
		and math.floor(level) == level)	--level is an integer

	local s = ("  "):repstring.rep(level) .. root:name() .. " (" .. root:type() .. ")"
	local children = root:getchildren()

	if #children == 0 then		--The array of children is empty.
		s = s .. "\n"
	else
		s = s .. " has " .. #children .. " satellite"
		if #children > 1 then
			s = s .. "s"	--plural
		end
		s = s .. ":\n"

		for i, child in ipairs(children) do
			s = s .. tree(child, level + 1)
		end
	end

	return s
end

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	local sol = celestia:find("Sol")
	local s = tree(sol, 0)
	celestia:print(s:sub(1, 2^10 - 1), 60, -1, 1, 1, -1) --upper left
	wait()
end

main()

The indentation shows who belongs to whom:

Sol (star) has 42 satellites: Mercury (planet) Venus (planet) Earth (planet) has 4 satellites: Moon (moon) Hubble (spacecraft) ISS (spacecraft) Mir (spacecraft) Mars (planet) has 2 satellites: Phobos (moon) Deimos (moon) etc.

A simpler way to perform the same traversal is with standard library method Object:familyObject:family. It returns an iterator which can be called over and over by a generic for loop, with each call returning a different celestial object. When the iterator returns nilnil (Lua value), the loop terminates. The level tells how far the object is located down the tree; the root is at level 0. The output is the same as in the previous example.

--[[
Traverse (in "preorder") the tree of celestial objects whose root is Sol.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)
	local s = ""

	for level, object in celestia:find("Sol"):family() do
		s = s .. ("  "):rep(level) .. object:name()
			.. " (" .. object:type() .. ")"

		local n = #object:getchildren()
		if n > 0 then
			s = s .. " has " .. n .. " satellite"
			if n > 1 then
				s = s .. "s"	--plural
			end
			s = s .. ":"
		end
		s = s .. "\n"
	end

	celestia:print(s:sub(1, 2^10 - 1), 60, -1, 1, 1, -1) --upper left
	wait()
end

main()

Write a recursive function named numberOfAncestors that returns an object’s number of ancestors. For example, the Moon has three ancestors: the Earth, Sun, and Solar System Barycenter.

local moon = celestia:find("Sol/Earth/Moon") local n = numberOfAncestors(moon)

The function should begin by asserting that its argument is a "userdata" and an [Object]. Then it should get the parent of the object, using the “info table” in infoTable. local info = object:getinfo() local parent = info.parent If the parent is nil, the object has no ancestors and we can return zero. If the parent is not nil, the object’s number of ancestors is the parent’s number of ancestors, plus 1.

[For Space CadetsSpace Cadet only.] Read the method Object:pathname in the standard library in stdCelx. Then let numberOfAncestors be a method of class Object. Add numberOfAncestors to the metatablemetatable (Lua) that is shared by every celestial object of class Object.

local moon = celestia:find("Sol/Earth/Moon") local n = moon:numberOfAncestors()

A Table of Fields

The keys of a table do not have to be integral, positive, or even numeric. For example, the keys of the following table are the strings "Aries", "Taurus", "Gemini", etc. A key/value pair whose key is a string is called a fieldfield (of table).

The first three fields are created without writing anything around the key. We can do this only if the key is a string that is a valid Lua variable name. The last three fields have keys that are not valid names. They are disqualified by the embedded blank in "Ursa Major", the leading digit in "4nax", and the special character ö in "Boötes". (The diaeresis may not be portable, and I don’t want to risk it. See specialCharacters for special characters.) If the key is not a valid name, it must be surrounded with [square brackets]. If the key is a string, it must also be surrounded with "double quotes".

A value can be accessed with a dot. (field operator) if its key is a valid Lua variable name; see epithet.Aries. The value must be accessed with square brackets if the key is not a valid name; see epithet["Ursa Major"].

We can loop through the fields of a table with the Lua function pairspairs (Lua function). Warning: the loop is guaranteed to visit every field in the table, but the order in which they will be visited is impossible to predict.

--[[
Loop through a table of fields (i.e., a table that is not an array)
with the function pairs.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	local epithet = {
		Aries = "Ram",		--"Aries" is the key, "Ram" is the value
		Taurus = "Bull",
		Gemini = "Twins",
		["Ursa Major"] = "Great Bear",  --"Ursa Major" is the key
		["4nax"] = "Furnace",
		["Bo" .. std.char.ouml .. "tes"] = "Herdsman"
	}

	local s = "Aries is the " .. epithet.Aries .. ".\n"
		.. "Ursa Major is the " .. epithet["Ursa Major"] .. ".\n\n"

	for key, value in pairs(epithet) do
		s = s .. key .. " the " .. value .. "\n"
	end

	celestia:print(s, 60, -1, 1, 1, -1)
	wait()
end

main()
Aries is the Ram. Ursa Major is the Great Bear. Taurus the Bull Ursa Major the Great Bear Aries the Ram 4nax the Furnace Boötes the Herdsman Gemini the Twins

The Info Tableinfo table

Each celestial object has a read-only table of fields called the info table. The table’s set of keys depends on the type of the object: a planet has an albedo, a star has a stellarClass, and a galaxy has a hubbleType. The corresponding values come from the database and catalogs files listed in the configuration file celestia.cfgcelestia.cfg (configuration file). The values are of many data types: name is a string, hasRings is a boolean, and parent is a celestial object or nil. A value that is neither a string nor a number must be explicitly converted tostring before it can be concatenatedconcatenation to another string with the .... (concatenation operator) operator. Just to be safe, we will convert all of them to strings.

The name, type, and radius fields of the info table have the same values as the name, type, and radius methods of class Object. (With one caveat. The radius method is always in kilometers. The radius field is in lightyears for deep sky objectsdeep sky object, and in kilometers for everything else.) The radius field of a planet is the equatorial radius, not the polar radius.

The height fields of the info table are in kilometers. The periods are in Earth days, even for comets. The atmosphereCloudSpeed is in radians per day, even though the CloudSpeed in data/solarsys.ssc is in degrees per day. Temperatures are in Kelvin (temperatureDistance), masses are in multiples of the Earth’s mass (miscDistance), and the lifespan endpoints are in TDB time (tdb). The infinitiesinfinity are formatted differently on each platform (inf on Mac and Linux, 1.#INF on Microsoft Windows) and are not to be taken as an endorsement of the Steady State theorySteady State theory (cosmology).

--[[
Display an object's info table.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	local object = celestia:find("Sol/Earth")
	local info = object:getinfo()
	assert(type(info) == "table")
	local s = ""

	for key, value in pairs(info) do
		s = s .. key .. " "

		if type(value) == "userdata" and tostring(value) == "[Object]"
			then
			s = s .. value:name()
		else
			s = s .. tostring(value)
		end

		s = s .. "\n"
	end

	celestia:print(s, 60, -1, 1, 1, -1)	--upper left corner of window
	wait()
end

main()
atmosphereCloudSpeed 1.1344640254974 type planet albedo 0.30000001192093 lifespanEnd inf parent Sol hasRings false atmosphereCloudHeight 7 atmosphereHeight 60 orbitPeriod 365.25 mass 0 name Earth lifespanStart -inf infoURL oblateness 0 rotationPeriod 0.99726955833333 radius 6378.1401367188

The info table uses double variables (doubleArithmetic) for four of the fields: lifespanStart and lifespanEnd, orbitPeriod and rotationPeriod. Print them in their full 15-digit glory. The other numeric fields are merely float variables and should be printed with only 6 significant digits.

if type(value) == "userdata" and tostring(value) == "[Object]" then s = s .. value:name() elseif type(value) ~= "number" then s = s .. tostring(value) elseif key == "orbitPeriod" or key == "rotationPeriod" or key == "lifespanStart" or key == "lifespanEnd" then s = s .. string.format("%.15g", value) --double else s = s .. string.format("%.6g", value) --float end atmosphereCloudSpeed 1.13446 albedo 0.3 radius 6378.14 etc.

Print the fields in alphabetical ordersorting of their keys. I wish we could pass the table to the Lua function table.sorttable.sort, but this function accepts only an array, not an arbitrary table of fields. The workaround is to copy the keys into an array, and then sort the array.

local keys = {} --Create an empty array. for key, value in pairs(info) do table.inserttable.insert(keys, key) end table.sort(keys)

We can now loop through the keys in alphabetical order.

for i, key in ipairsipairs (Lua function)(keys) do value = info[key] s = s .. key .. " " if type(value) == "userdata" and tostring(value) == "[Object]" then --etc. albedo 0.3 atmosphereCloudHeight 7 atmosphereCloudSpeed 1.13446 atmosphereHeight 60 hasRings false etc.

Display the info table for the star "Betelgeuse"Betelgeuse (star), the globular cluster "M 13"M13 (globular cluster) in HerculesHercules (one space after the M), the Andromeda GalaxyAndromeda Galaxy (M31) "M 31"M31 (Andromeda Galaxy), and the city "Washington D.C."Washington, DC (no comma, one space, two periods).

Lua, Celx, and the Celx Standard Library have many tables of fields. Print some of them.

local t = celestia:getrenderflags() --values are booleans local t = celestia:getlabelflags() local t = celestia:getorbitflags() local t = celestia:getoverlayelements() local t = celestia:getobserver():getlocationflags() local t = celestia:tdbtoutc(celestia:gettime()) --values are numbers local t = celestia:fromjulianday(celestia:gettime()) local t = os.date("*t") --must first call celestia:requestsystemaccess() local t = _G --values are all the global variables local t = getmetatable(celestia) --values are methods local t = package.loaded --values are tables local t = std.dayName --values are strings local t = std.monthName local t = std.zodiac local t = std.char --values are special characters local t = std.constellations --values are arrays of strings

One inconsistency. The info table of "Sol/Earth/Washington D.C." lists the Earth as its parent, but the Earth’s getchildren method (tableOfObjects) does not list Washington. To get the cities and other surface features of a celestial object, use the iterator returned by Object:locationsObject:locations.

local object = celestia:find("Sol/Earth") local s = "" --[[ The method Object:locations returns an iterator. When we call a method, we write it with a colon. When we refer to a method without calling it, we write it with a dot. ]] assert(type(object.locations) == "function" and type(object:locations()) == "function") for location in object:locations() do assert(type(location) == "userdata" and tostring(location) == "[Object]" and location:type() == "location") local info = location:getinfo() s = s .. info.name .. " (" .. info.featureType .. ") " .. info.size .. " km\n" --diameter, not radius end

The nomenclature is surprisingly Latinate: the Earth’s continents are terræ and its oceans are maria. We’re lucky that Washington isn’t an urbis.

NORTH AMERICA (terra) 5000 km NORTH ATLANTIC OCEAN (mare) 6400 km Washington D.C. (city) 1 km etc.

The Stefan-Boltzmann LawStefan-Boltzmann law

Mathematics supplied the background of imaginative thought with which men of science approached the observation of nature. Galileo produced formulae, Descartes produced formulae, Huyghens produced formulae, Newton produced formulae.
Alfred North WhiteheadWhitehead, Alfred North, Science and the Modern WorldScience and the Modern World (Whitehead) (1925).

This section illustrates a few applications of the info table. We will read the albedo and parent fields of a planet, and the bolometricMagnitude and temperature fields of a star.

The Stefan–Boltzmann Law says that when a body’s temperature is doubled, the outflow of energy through its surface is multiplied by a factor of 16. The exact statement of the law is S = σT4 where T is the temperature in Kelvin, S is the outward flux in wattswatt per square meter, and σσ (Stefan-Boltzmann constant) is the Stefan–Boltzmann constantStefan-Boltzmann constant with a value of 5.670372 · 10–8 Wm–2 K–4. For big objects, we will work with square kilometers instead of square meters. The σ, by the way, is the Greek lowercase letter sigma. It’s in the Celx standard library as std.char.sigma.

The temperature of a star can be read by a Celx program from the temperature field of the star’s info table (infoTable). The temperature of a non-luminous body—a planet, moon, asteroid, comet, spacecraft, or exoplanet—is not available to the program, but is displayed on the screen when Celestia is in verbose mode. On Mac,
Celestia → Preferences → General → Info Display: Verbose
On Windows,
Render → View Options… → Information Text: Verbose
Then select the body as in keystroke.

The non-luminous temperature is an estimate derived from the following calculation, which we will reproduce in Celx. Temperatures are in Kelvin; distances are in kilometers; areas are in square kilometers.

Let R be the radius of the Sun (or nearest star). Then the surface area of the Sun is 4πR2 Let T be the surface temperature of the Sun. Then the total outward flux coming through the surface is (4πR2)(σT4) Let d be the distance from the Sun to the planet (or other non-luminous body). Picture the planet on the surface of a sphere centered at the Sun. The radius of this sphere is d and its area is 4πd2 The flux per square kilometer through the surface of the sphere is therefore (4πR2)(σT4) 4πd2  =  R2σT4 d2 Let r be the radius of the planet. The area of the disk that the planet presents to the Sun is πr2 The total flux received by the planet is therefore R2σT4 d2 πr2 But a certain fraction of this flux is reflected back into space. This fraction is called the albedo and denoted by a. The fraction absorbed by the planet is 1 − a, so the total flux absorbed is only R2σT4 d2 πr2(1 − a) Meanwhile, the planet is also radiating energy. The surface area of the planet is 4πr2 Let t be the temperature of the planet. The outgoing total flux through the surface of the planet is (4πr2)(σt4) If the temperature of the planet is constant, the incoming and outgoing flux must be equal: R2σT4 d2 πr2(1 − a)  =  (4πr2)(σt4) We can solve this equation for t4, t4 = R2T4(1 − a) 4d2 and then for t. t = T(1 − a).25(R/2d).5

--the non-luminous celestial object whose temperature is sought local object = celestia:find("Sol/Mars") celestia:select(object) --display temperature in verbose mode --Find the object's star. assert(object:type() ~= "star") local star = object repeat star = star:getinfo().parent assert(star ~= nil) until star:type() == "star" local distance = star:getposition():distanceto(object:getposition()) assert(distance > 0) --Don't divide by zero. local temperature = star:getinfo().temperature * (1 - object:getinfo().albedo)^.25 * (star:radius() / (2 * distance))^.5 local s = string.format("%.15g K", temperature) 226.707505428186 K

Does the above code agree with Celestia’s estimates? Get the real temperatures of the planets from Wikipedia and observe that Celestia’s estimates are correct for MarsMars, temperature of and PlutoPluto, temperature of. But why are they too low for the EarthEarth, temperature of and way too low for VenusVenus, temperature of?

Celestia tries to get the radiusradius of star of a star from the file data/charm2.stccharm2.stc (data file). If the star is not found, it estimates the radius from the star’s luminosityluminosity of star and temperature. We will reproduce the estimate in Celx. As above, temperatures are in Kelvin, distances are in kilometers, and areas are in square kilometers.

For reference purposes, we begin by computing the luminosity of the Sun. Let R be the radius of the Sun. Then its surface area is 4πR2 Let T be the surface temperature of the Sun. Then the total outward flux coming through the surface is (4πR2)(σT4)

This total flux is called the Sun’s bolometric magnitudebolometric magnitude. Unlike plain old magnitude, itmagnitude, bolometric includes the Sun’s power output in ultraviolet, visible, infrared, radio, and all other wavelengths.

The value of the bolometric magnitude gets smaller as the star’s power output increases. For example, a star with 100 times the output of the Sun would have a bolometric magnitude that is 5 less than the Sun’s. Let’s break it down into smaller intervals. A star with 1001/5 ≈ 2.51188643150958 times the output of the Sun has a bolometric magnitude that is 1 less than the Sun’s. And a star with 1002/5 ≈ 6.30957344480193 times the output of the Sun has a bolometric magnitude that is 2 less than the Sun’s. See loopDatabase for the convention of “five magnitudes span a factor of 100.” With the star’s bolometric magnitude, we can compute how many times brighter it is than the Sun. Let’s call this factor f. The total output of the star is therefore (4πR2)(σT4)f Let r be the radius of the star. Then its surface area is 4πr2 Let t be the surface temperature of the star. Then its total output is (4πr2)(σt4) But we determined that this quantity is equal to f times the Sun’s output: (4πr2)(σt4) = (4πR2)(σT4)f Solving for r yields the radius of the star. r = T2 t2 R f 

Let’s pick a star that is not in data/charm2.stc, such as ThubanThuban (star) or Castor.

--the star whose radius is sought local thuban = celestia:find("Thuban") --Alpha Draconis celestia:select(thuban) --Display its radius. local starInfo = thuban:getinfo() local solInfo = celestia:find("Sol"):getinfo() --The star puts out f times as much power as the Sun. local difference = solInfo.bolometricMagnitude - starInfo.bolometricMagnitude local f = (100^.2) ^ difference assert(starInfo.temperature > 0) --Don't divide by zero. local starRadius = solInfo.radius * math.sqrt(f) * (solInfo.temperature / starInfo.temperature)^2 local s = string.format("Radius = %.15g kilometers", starRadius)

Celestia displays Thuban’s radius as 4,110,000 kilometers, because it rounds it to three significant digits.

Radius = 4105400.09838207 kilometers

A note for readers of the C++ member function Star::getRadius in version/src/celengine/star.cpp. The cryptic LN_MAGLN_MAG (macro) is

5 ln 100 ≈ 1.08573620475813

The above calculation gives us the following radius for "Sirius A"Sirius (star), in good agreement with the radius field of that star’s info table. Radius = 1337211.70699272 kilometers Butbug in Celestia the radius of Sirius A is supposed to be 1,180,000 kilometers because of the following entry in data/charm2.stc.

#Excerpt from data/charm2.stc. Sirius has Hipparcos catalog number 32349. #The distance in kilometers from the Solar System Barycenter to Sirius, #times the sine of 3 milliarcseconds, is approximately 1,180,000 kilometers. Modify 32349 { Radius 1180000 # Limb darkened angular diameter = 6.00 mas }

What went wrong? Hint:

--[[ "Sirius" is the barycenter of the Sirius system; "Sirius A" and "Sirius B" are the stars. The radius of a stellar barycenter is supposed to be .001 kilometers. ]] local barycenter = celestia:find("Sirius") local info = barycenter:getinfo() local s = string.format( "name %s\n" .. "catalogNumber %u\n" .. "stellarClass %s\n" .. "radius %.15g kilometers", info.name, info.catalogNumber, info.stellarClass, info.radius) name Sirius catalogNumber 32349 stellarClass Bary radius 1180000 kilometers

The Renderflagsrenderflags

The renderflags are a table of read/write boolean values that determine what features of the Universe are rendered. These include celestial objects such as planets and galaxies, reference lines such as grids and orbits, and rendering styles such as smoothlines and automag. See Object:setorbitvisibilityObject:setorbitvisibility for the complicated interactionorbit visibility between the renderflags and the orbitflags.

Let’s turn the flags off, sit in the dark for a few seconds, and restore them on exit with celestia_cleanup_callbackcelestia_cleanup_callback (function) (callback). See hertzsprungrussell for an example where we actually do turn off all the renderflags. The callback function uses the local variable save, so it would make no sense for the function to outlive this variable. But a callback has to be nonlocal.

--[[
Set the renderflags to sane values and print them.
Then turn them all off, wait in the dark for a few seconds,
and restore them to their sane values.
]]
require("std")

--Variables used by more than one function.
local save = {}		--Create an empty table.
local duration = 10	--seconds of real time

local function printFlags()
	local s = ""
	for key, value in pairs(celestia:getrenderflags()) do
		s = s .. key .. " " .. tostring(value) .. "\n"
	end
	return s
end

function celestia_cleanup_callback()		--can't be local
	--Restore the original flags.
	celestia:setrenderflags(save)
	celestia:print("Back to sane renderflags:\n" .. printFlags(),
		duration, -1, 1, 1, -1)
	wait(duration)
	celestia_cleanup_callback = nil		--Destroy this function.
end

local function main()
	local observer = celestia:sane()	--Changes the renderflags.
	observer:setposition(std.position)

	--Save the flags.
	local flags = celestia:getrenderflags()
	for key, value in pairs(flags) do
		save[key] = value
	end

	celestia:print("Renderflags set by Celestia:sane:\n" .. printFlags(),
		duration, -1, 1, 1, -1)
	wait(duration)

	--Turn off all the flags.  Sit in darkness for duration seconds.
	for key, value in pairs(flags) do
		flags[key] = false
	end
	celestia:setrenderflags(flags)

	celestia:print("Renderflags off:\n" .. printFlags(),
		duration, -1, 1, 1, -1)
	wait(duration)
end

main()

Here are the values set by Celestia:saneCelestia:sane, alphabetized for your convenience.

atmospheres true automag true boundaries false cloudmaps true cloudshadows true comettails true constellations true eclipseshadows true ecliptic true eclipticgrid false equatorialgrid true galacticgrid false galaxies true globulars true grid true horizontalgrid false lightdelay false markers true nebulae true nightmaps true openclusters true orbits false partialtrajectories true planets true ringshadows true smoothlines true stars true

Save and restore the label flags too, with Celestia:getlabelflagsCelestia:getlabelflags and Celestia:setlabelflagsCelestia:setlabelflags. Save and restore the orbitflags with Celestia:getorbitflagsCelestia:getorbitflags and Celestia:setorbitflagsCelestia:setorbitflags. What about numeric values such as the ambient light level or the galaxy light gain? See the method Celestia:sane in stdCelx for the complete list of savable properties.

Here are two ways to do the same thing. Which is easier?

--Turn on ecliptic and eclipticgrid, --turn off cloudmaps and cloudshadows. local flags = celestia:getrenderflags() flags.ecliptic = true flags.eclipticgrid = true flags.cloudmaps = false flags.cloudshadows = false celestia:setrenderflags(flags) --Turn on ecliptic and eclipticgrid, --turn off cloudmaps and cloudshadows. celestia:showCelestia:show("ecliptic", "eclipticgrid") celestia:hideCelestia:hide("cloudmaps", "cloudshadows")

Test if a Key Exists

The special value nilnil (Lua value) can never be the value—or the key—of any field in a table. To test if a key exists, it therefore suffices to check if the corresponding value is nil. The following example loops through all the ancestors of a celestial object. The loop will keep going as long as there is a key named parent in the info table of the current object. See Object:pathname.

--[[
Loop through all the ancestors of an object.  Print them one per line.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	local object = celestia:find("Sol/Earth/Moon/Tycho")
	local s = object:name() .. " and its ancestors are\n"
	local i = 0

	repeat
		i = i + 1
		local info = object:getinfo()
		local type = object:type()
		if type == "location" then
			type = type .. ", " .. info.featureType
		elseif type == "star" then
			type = type .. ", " .. info.stellarClass
		end
		s = s .. string.format("%d %s (%s)\n", i, info.name, type)
		object = info.parent
	until object == nil

	local duration = 60
	celestia:print(s, duration, -1, 0, 1, 0)
	wait(duration)
end

main()
Tycho and its ancestors are 1 Tycho (location, crater) 2 Moon (moon) 3 Earth (planet) 4 Sol (star, G2V) 5 Solar System Barycenter (star, Bary)

Loop Through a Database

Celestia comes with a database of a hundred thousand stars, including miscellaneous objects such as neutron stars and the barycenters of stellar systems. We can loop through the database with the iterator returned by Celestia:starsCelestia:stars.

The ArabicArabic language word for “head” is رأس (ra’s). Let’s find every star that’s a head. The following [Rr] is a wildcardwildcard (in pattern) that looks for an upper or lowercase letter R. A wildcard is a simple example of a patternpattern (Lua).

--[[
List the stars that are heads ("ras" in Arabic).
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)
	wait()	--Render a view for the user to watch during the long loop.
	local s = ""

	--The method Celestia:stars returns an iterator.
	assert(type(celestia.stars) == "function"
		and type(celestia:stars()) == "function")

	for star in celestia:stars() do
		assert(type(star) == "userdata"
			and tostring(star) == "[Object]"
			and star:type() == "star")

		--If it's really a star,
		--and not the barycenter of a stellar system,
		if star:getinfo().stellarClass ~= "Bary" then

			local name = star:name()
			if name:findstring.find("[Rr]as") then	--"head (of)" in Arabic
				s = s .. name .. "\n"
			end
		end
	end

	celestia:print(s, 60, -1, 1, 1, -1)
	wait()
end

main()

Comments are in parentheses.

Rasalgethi (α Herculis, Head of the Kneeler) Rastaban (β Draconis, Head of the Serpent [cf. Thuban]) Ras Elased Australis (ε Leonis, Head of the Lion, south) Rasalas (μ Leonis, Head of the Lion) Rasalhague (α Ophiuchi, Head of the Serpent)

The Arabic word for “tail” is ذنب (danb). Let’s find the tails. The wildcard %a%a (in pattern) looks for any letter, upper or lowercase. The expression %a** (in pattern) looks for any string of zero or more consecutive letters. This ensures that the D, N, and B will belong to the same word, with no whitespace between them.

--"tail (of) in Arabic" if name:find("[Dd]%a*n%a*b") then

We missed Deneb KaitosDeneb Kaitos (star) (β Ceti, the tail of CetusCetus) because its name method returns "Diphda"Diphda (star). To examine all the names of a star, see inputFile.

Al Dhanab al Dulfim (ε Delphini, the Tail of the Dolphin) Al Dhanab (γ Gruis, The Tail) Deneb el Okab (ζ Aquilæ, Tail of the Falcon) Denebola (β Leonis, Tail of the Lion) Deneb Algedi (δ Capricorni, the Head of the Goat) Deneb (α Cygni, Tail par excellence)

Find the stars whose names contain “of the”. if name:find(" [AaEe]l") then --"of the" in Arabic We required a space before the letters to avoid finding words such as Camelopardalis or Velorum. But the space caused us to miss many celebrated stars: ZubenelgenubiZubenelgenubi (star) (α Libræ, the Southern Claw), FomalhautFomalhaut (star) (α Piscis Austrini, the Mouth of the Whale), and BetelgeuseBetelgeuse (star) (α Orionis, the Hand of Orion).

Na'ir al Saif (ι Orionis, Bright One of the Sword) Minhar al Shuja (σ Hydræ, Nose of the Hydra) Ras Elased Australis (ε Leonis, Head of the Lion, south) Al Dhanab al Dulfim (ε Delphini, the Tail of the Dolphin) Deneb el Okab (ζ Aquilæ, Tail of the Falcon) Deneb Algedi (δ Capricorni, the Head of the Goat) Zuben Elakribi (δ Libræ, the Claw of the Scorpion) Zuben Elakrab (γ Libræ, the Claw of the Scorpion) Fum al Samakah (β Piscium, the Mouth of the Fish)

We can get the planets of a star by calling its getchildrenObject:getchildren method (tableOfObjects). Let’s go prospecting for exoplanetsexoplanet with liquid waterwater, liquid. See temperatureDistance for the temperature calculation.

local freeze = 273.15 --273.15 degrees Kelvin is 0 degrees Celsius local boil = freeze + 100 for star in celestia:stars() do if star:getinfo().stellarClass ~= "Bary" then for i, child in ipairs(star:getchildren()) do local distance = star:getposition() :distanceto(child:getposition()) local temperature = star:getinfo().temperature * (1 - child:getinfo().albedo)^.25 * (star:radius() / (2 * distance))^.5 --liquid water if freeze <= temperature and temperature <= boil then s = s .. string.format( "%-16s %7.3f%s C\n", star:name() .. "/" .. child:name(), temperature - freeze, std.char.degree) end end end end

Why didn’t it find the Earth?

BD+20 2457/b 39.249° C BD+20 2457/c 3.072° C TYC 3304-101-1/b 35.913° C etc.

To find the neutron starsneutron star, look for the stars whose stellarClass starts with Q.

--Caretcaret (anchor in pattern) (^^ (anchor in pattern)) means "starts with". if star:getinfo().stellarClass:findstring.find("^Q") then

Are there any black holesblack hole (stellar class "X")? If not, create the following “star catalog” file named imaginary.stcstc file, create your own in the data subdirectory of the Celx directory (celxDirectory).

#This file is data/imaginary.stc. #Hipparcos numbersHipparcos catalog for imaginary stars should be in the range 300,000 #to 600,000. "MyBlackHole:HIP 300000" { RA 0 Dec 0 Distance .000001 #one microlightyear from universal frame origin SpectralType "X" #black hole AbsMag 0 #absolute magnitude }

Then add the filename "data/imaginary.stc" to the list of StarCatalogsStarCatalogs (in celestia.cfg) in the configuration file celestia.cfg and restart Celestia. Does the black hole bathe the Earth in a rosy light? Select MyBlackHole and go there with the g keystroke (keystroke). Is the surface of the black hole sharp or fuzzy? (On Mac, the console says we load an image from the file textures/lores/astar.jpg and I get a white, sharp hole. On Microsoft Windows, I sometimes get a black, fuzzy hole.) Go in for a closer look.

List the stars within 8.75 lightyears of the Solar System Barycenter, with the distance in lightyears of each star. Do not list Sol or any barycenter.

std.position0 is a position object (position) representing the position of the Solar System Barycenter. The difference of two positions is a vector object (vectors), an arrow from one position to the other. The lengthVector:length of a vector is measured in microlightyears (linearDistance), so we divide it by one million to convert it to lightyears. Celestia knows most stellar distances to only at most 10 significant digits (see version/data/stars.txt), so we format them with %.10g.

local s = "" for star in celestia:stars() do local name = star:name() if name ~= "Sol" and star:getinfo().stellarClass ~= "Bary" then local vector = star:getposition() - std.position0 local microlightyears = vector:length() local lightyears = microlightyears / 1000000 if lightyears < 8.75 then s = s .. string.format("%11.10f %s\n", lightyears, name) end end end

Rigel Kentaurus (The Foot of the Centaur) is the fabled Alpha Centauri.

8.5829180150 Sirius A 4.3652143469 Rigel Kentaurus A 4.3647411792 Rigel Kentaurus B 8.2900000352 Gliese 411 4.2420000986 Proxima 5.9630000000 Barnard's Star 7.7819998624 CN Leo 8.5831586381 Sirius B 8.7280489403 BL Cet 8.7279503161 UV Cet

List the 10 stars closest to the Solar System Barycenter, in order of increasing distance. Do not list Sol or any barycenter.

The following t will be an array. Each value in t will be a short array containing the name and distance in microlightyears of a star. We will sortsorting t with the closest stars first. In infoTable we saw the function table.sorttable.sort, which applied the operator < to pairs of values in the array to determine which value of the pair should come first. This is okay if the values are numbers or strings. But the values in t are arrays, and < cannot be applied to a pair of arrays. We must therefore give sort a two-argument comparison function to use in place of <.

--[[ Create the increasingDistance function above the main function. a and b are arrays, each containing two values. a[1] and b[1] are the names of two stars; a[2] and b[2] are their distances from the Solar System Barycenter. Return true if star b should come after star a. ]] local function increasingDistance(a, b) return a[2] < b[2] end --in the main function local t = {} --Create an empty array. for star in celestia:stars() do local name = star:name() if name ~= "Sol" and star:getinfo().stellarClass ~= "Bary" then local vector = star:getposition() - std.position0 table.insert(t, {name, vector:length()}) end end --wait() --if Celestia needs to catch its breath --Sort t in order of increasing distance. table.sort(t, increasingDistance) local s = "" for i = 1, 10 do s = s .. string.format("%11.10f %s\n", t[i][2] / 1000000, t[i][1]) end 4.2420000986 Proxima 4.3647411793 Rigel Kentaurus B 4.3652143469 Rigel Kentaurus A 5.9630000000 Barnard's Star 7.7819998624 CN Leo 8.2900000352 Gliese 411 8.5829180151 Sirius A 8.5831586380 Sirius B 8.7279503161 UV Cet 8.7280489403 BL Cet

Better yet, let the comparison function be an anonymous function.

--[[ a and b are arrays, each containing two values. a[1] and b[1] are the names of two stars; a[2] and b[2] are their distances from the Solar System Barycenter. ]] table.sort(t, function(a, b) return a[2] < b[2] end)

List the stars with the greatest apparent magnitudeapparent magnitude (brightnessmagnitude, apparent) as seen from Earth. A first magnitude star appears 100 times brighter than a sixth magnitude star, so five units of magnitude span a factor of 100 in brightness.

The info table does not contain a star’s apparent magnitude, but it does contain the absolute magnitudemagnitude, absolute. The absolute magnitudeabsolute magnitude of a star is the apparent magnitude it would have at a distance of 10 parsecs. Given a star’s absolute magnitude M and its distance d in parsecs, we can calculate its apparent magnitude m:

m = M + 5 · (log10 d − 1)

Here’s where the logarithm comes from. For a star whose d = 10, the absolute and apparent magnitudes should be equal by definition. And they are equal, because log10 d = 1. For a star 10 times more distant but otherwise identical, the light we receive is 100 times fainter because of the inverse-square law. But this is the same factor of 100 in brightness that was spanned by 5 units of magnitude. Thus the apparent magnitude of the distant star should be 5 greater than that of the original star. And it is 5 greater, because when d gets 10 times bigger, the expression log10 d increases by 1. This causes the expression 5 · (log10 d − 1) to increase by 5.

The absolute magnitudes of most stars are known to Celestia to only at most three significant digits (see version/data/stars.txt), so we print only a few digits. Warning: In Lua 5.2, the old function math.log10math.log10. is no longer present. We would call math.logmath.log instead, with a second argument of 10. But the math.log in the Lua 5.1 in Celestia 1.6.1 accepts only one argument, so for the time being we will continue to call the obsolete math.log10.

local s = "" for star in celestia:stars() do local info = star:getinfo() if info.name ~= "Sol" and info.stellarClass ~= "Bary" then local vector = star:getposition() - std.position0 local microlightyears = vector:length() local lightyears = microlightyears / 1000000 local parsecs = lightyears / std.lyPerPc local apparentMagnitude = info.absoluteMagnitude + 5 * (math.log10(parsecs) - 1) if apparentMagnitude <= .451 then --just the brightest s = s .. string.format("%7.4f %s\n", apparentMagnitude, info.name) end end end 0.1829 Rigel 0.4501 Betelgeuse -0.6162 Canopus -0.0474 Arcturus 0.0601 Capella A 0.0801 Capella B 0.0281 Vega -1.4299 Sirius A 0.3801 Procyon A 0.0102 Rigel Kentaurus A

Sort the stars in order of apparent magnitude as seen from earth, brightest stars first. Exclude Sol and the barycenters. Then list the first ten. You should see the table that appears in every astronomy textbook:

-1.4299 Sirius A -0.6162 Canopus -0.0474 Arcturus 0.0102 Rigel Kentaurus A 0.0281 Vega 0.0601 Capella A 0.0801 Capella B 0.1829 Rigel 0.3801 Procyon A 0.4501 Betelgeuse

List the 10 brightest stars as seen from Alpha Centauri. Exclude Alpha Centauri A and B and the barycenters.

List the 10 stars of the greatest angular diameter as seen from the Solar System Barycenter. Print the name and diameter in milliarcseconds of each star. AntaresAntares (star) and Betelgeuse should be near the top of the list. Be sure to use the same unit of measurement (kilometers or microlightyears) for both the radius and the distance of each star.

Celestia believes that all the stars of a given stellar class share the same temperature. List the name and Kelvin temperature of each stellar type, one per line, in order of decreasing temperature.

--Create a table whose keys are stellar types and whose values are --Kelvin temperatures. local t = {} for star in celestia:stars() do local info = star:getinfo() if info.stellarClass ~= "Bary" then t[info.stellarClass] = info.temperature end end wait() --Copy the table into an array so we can sort it. local array = {} for key, value in pairs(t) do table.insert(array, {key, value}) end wait() --[[ Sort in order of decreasing temperature: Oh Be A Fine Girl/Guy, Kiss Me. If temperatures are equal, sort in alphabetical order. ]] table.sort(array, function(a, b) if a[2] ~= b[2] then --a[2] and b[2] are temperatures. return a[2] > b[2] end return a[1] < b[1] --a[1] and b[1] are names. end) local s = "" for i, value in ipairs(array) do s = s .. string.format("%3d %7.0f %s\n", i, value[2], value[1]) end

Type Q is a neutron star.

1 5000000 Q 2 60000 WC6 3 50400 DA1 etc. 497 1350 T1V 498 1020 T6V 499 800 T8V

The Gould BeltGould Belt (of stars) is a disk or ring of young, hot stars of spectral types O and B. Since we happen to be near the middle of the disk, it looks like a belt of stars along a great circlegreat circle around the sky. The plane of the belt is tilted 20° from the galactic planegalactic plane, crossing it at galactic longitude ℓ = 102° (in Vela) and ℓ = 282° (in Cephus). The belt is above (i.e., north of) the Milky Way in CentaurusCentaurus, LupusLupus (constellation), and ScorpiusScorpius, and below the Milky Way in PerseusPerseus, OrionOrion, and Canis MajorCanis Major.

Let’s mark the OB stars whose apparent magnitude is brighter than 4.5. Then pan around the sky manually, keeping the galactic plane level in the middle of the window. Compare the number of marked stars above and below the plane at each longitude. Experiment with different thresholds of magnitude.

celestia:hide("ecliptic", "grid") celestia:show("boundaries", "galacticgrid") --Mark the conspicuous stars of the Gould Belt. for star in celestia:stars() do local info = star:getinfo() if info.stellarClass:find("^[BO]") then local vector = star:getposition() - std.position0 local microlightyears = vector:length() local lightyears = microlightyears / 1000000 local parsecs = lightyears / std.lyPerPc local apparentMagnitude = info.absoluteMagnitude + 5 * (math.log10(parsecs) - 1) if apparentMagnitude < 4.5 then star:mark("green", "diamond", 10, .9, info.stellarClass) end end end

Every star and barycenter has an integer ID number in the database. The numbers start at zero and go up to one less than the number returned by the method Celestia:getstarcountCelestia:getstarcount. The ID number is not the star’s number in any of the three catalog (Hipparcos, Henry Draper, or Smithsonian Astrophysical Observatory).

assert(type(celestia:getstarcount()) == "number") --Loop through all the stars. for id = 0, celestia:getstarcount() - 1 do local star = celestia:getstarCelestia:getstar(id) assert(type(star) == "userdata" and tostring(star) == "[Object]" and star:type() == "star") if star:getinfo().stellarClass ~= "Bary" then --etc. end

Why would we want to loop with Celestia:getstarcount and a numeric for loop rather than with Celestia:stars and a generic for loop?

Celestia comes with a database of deep sky objectsdeep sky object (DSOsDSO (deep sky object)). It contains eleven thousand galaxiesgalaxy, 150 globular clustersglobular cluster belonging to the Milky WayMilky Way, orbited by globulars, no open clustersopen cluster, and no nebulænebula. The database has its own trio of methods:

stars DSOs
returns an iterator Celestia:stars Celestia:dsos
returns a number Celestia:getstarcount Celestia:getdsocount
returns an object Celestia:getstar Celestia:getdso

Let’s print the average radius in lightyears of the spiral, elliptical, and irregular galaxies. The hubbleTypehubbleType (of galaxy) of a galaxy is a string starting with S, E, or I. The method string.substring.sub returns the first character of the string.

--A table containing three arrays. --Each array contains a name, count, and sum of radii. local hubbleType = { S = {"Spiral", 0, 0}, E = {"Elliptical", 0, 0}, I = {"Irregular", 0, 0} } for dso in celestia:dsos() do --vs. "globular", "opencluster", or "nebula" if dso:type() == "galaxy" then local info = dso:getinfo() local t = hubbleType[info.hubbleType:sub(1, 1)] t[2] = t[2] + 1 t[3] = t[3] + info.radius --in lightyears end end wait() --Let Celestia catch its breath. local s = "Type # Radius\n" for key, value in pairs(hubbleType) do s = s .. string.format("%-10s %4d", value[1], value[2]) if value[2] > 0 then --Don't divide by 0. s = s .. string.format(" %7.1f", value[3] / value[2]) end s = s .. "\n" end

The irregulars are much smaller than the other types.

Type # Radius Elliptical 1725 37523.0 Spiral 8548 41797.4 Irregular 664 18839.0

What percentage of the spirals are barred (Hubble types "SBa", "SBb", "SBc")? Are the irregulars closer to their neighbors than the other two types? Is there a tendency for two neighboring spirals to spin in opposite directions? The last question will be answered in galacticCoordinates.

The Hertzsprung-Russell DiagramHertzsprung-Russel diagram

This section uses Celestia’s database of stars to construct the Hertzsprung-Russell diagram, which plots each star as a point in a two-dimensional space. The origin is in the lower right corner. The horizontal axis is the star’s temperature, increasing from right to left. The vertical axis is the star’s luminosity, increasing from bottom to top. The majority of stars lie along a diagonal line, called the main sequencemain sequence (of stars), from the lower right (cool and dim) to the upper left (hot and bright). An arm of giant starsgiant star extends to the upper right (cool and bright, hence very big).

Values in the diagram are plotted logarithmicallylogarithm: when we double the temperature, we always move to the left by the same distance. Thus the horizontal distance from 3,000 to 6,000 Kelvin is equal to the distance from 6,000 to 12,000 Kelvin.

If the star’s temperature is in the range 10self.logMinTemp = 2,500 Kelvin to 10self.logMaxTemp = 50,000 Kelvin inclusive, then the log10 of its temperature will be in the range self.logMinTemp to self.logMaxTemp inclusive. The size of this range is self.rangeTemp. The expression               (math.log10(info.temperature) - self.logMinTemp) / self.rangeTemp will be a fraction in the range 0 to 1 inclusive. And the expression -self.width * (math.log10(info.temperature) - self.logMinTemp) / self.rangeTemp will be a distance in pixels in the range 0 to negative the width of the window, inclusive. (We want negative because the origin is in the lower right. A point to the left of the origin will have a negative x coördinate.) Luminosity is plotted similarly. Warning: the function math.log10 is no longer present in Lua 5.2.

The array self.a contains more than one hundred thousand values. Each value is an array of two numbers giving a star’s horizontal and vertical position in pixels measured from an origin at the lower right corner of the Celestia window. Depending on the speed of your machine, it might take several seconds to create self.a. This is a problem because a Celx program must call the waitwait function (wait) at least once every five seconds, and a call to wait inside a Lua hook never returns. Our solution is to increase the five-second limit by calling Celestia:settimesliceCelestia:settimeslice. When the entire database has been read into self.a, the tick hook wipes itself out by saying self.tick = nil.

--[[
This file is luahook.celx.
Plot the Hertzsprung-Russell diagram, featuring the main sequence of stars.
]]

celestia:setluahook {
	width = nil,		--of window, in pixels
	height = nil,
	a = {},			--array of stars, not including barycenters

	--logs of surface temperature in Kelvin
	logMinTemp = math.log10(2.5e3),	--coolest, right edge of window
	logMaxTemp = math.log10(5e4),	--hottest, left edge of window
	rangeTemp = nil,

	--logs of luminosity in multiples of Sol's
	logMinLum = math.log10(3e-4),	--dimmest, bottom edge of window
	logMaxLum = math.log10(2e4),	--brightest top edge of window
	rangeLum = nil,

	--Return the x, y coordinates in pixels of the given point,
	--measured from an origin in the lower right corner of the window.

	coordinates = function(self, temperature, luminosity)
		return -self.width
			* (math.log10(temperature) - self.logMinTemp)
			/ self.rangeTemp,

			self.height
			* (math.log10(luminosity) - self.logMinLum)
			/ self.rangeLum
	end,

	tick = function(self)
		self.tick = nil	--No need to call this hook more than once.
		require("std")
		self.rangeTemp = self.logMaxTemp - self.logMinTemp
		self.rangeLum = self.logMaxLum - self.logMinLum
		self.width, self.height = celestia:getscreendimension()

		celestia:settimeslice(10) --Ask for even more seconds if needed.
		for star in celestia:stars() do
			local info = star:getinfo()
			if info.stellarClass ~= "Bary" then
				table.insert(self.a, {self:coordinates(
					info.temperature, info.luminosity)})
			end
		end
		celestia:settimeslice(5)	--Restore the default.
	end,

	renderoverlay = function(self)
		gl.MatrixMode(gl.PROJECTION)
		gl.PushMatrix()
		gl.LoadIdentity()

		--dimensions of the Celestia window, in pixels
		local width, height = celestia:getscreendimension()
		glu.Ortho2D(0, width, 0, height)   --left, right, bottom, top

		gl.MatrixMode(gl.MODELVIEW)
		gl.PushMatrix()
		gl.LoadIdentity()

		--The default origin (0, 0) is at the lower left corner of the
		--window.  Move the origin to the lower right corner.
		gl.Translate(width, 0)

		gl.Enable(gl.BLEND)
		gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
		gl.Disable(gl.LIGHTING)	--Make text and graphics self-luminous.
		gl.Disable(gl.TEXTURE_2D)	--disabled for graphics
		gl.Color(1, 1, 1, 1)		--red, green, blue, alpha

		gl.Begin(gl.POINTS)		--isolated points
		for i, value in ipairs(self.a) do
			gl.Vertex(value[1], value[2])
		end
		gl.End()

		--Yellow square around Sol.
		gl.Color(1, 1, 0, 1)
		local size = 2
		local info = celestia:find("Sol"):getinfo()

		local solX, solY =
			self:coordinates(info.temperature, info.luminosity)

		gl.Begin(gl.LINE_LOOP)			--connected lines
		gl.Vertex(solX + size, solY - size)	--lower right corner
		gl.Vertex(solX + size, solY + size)	--upper right
		gl.Vertex(solX - size, solY + size)	--upper left
		gl.Vertex(solX - size, solY - size)	--lower left
		gl.End()

		gl.MatrixMode(gl.MODELVIEW)
		gl.PopMatrix()

		gl.MatrixMode(gl.PROJECTION)
		gl.PopMatrix()
	end
}
--[[
Provide a black background for the OpenGL graphics.  Turn off all the flags.
]]
require("std")

local function main()
	celestia:sane()

	local flags = celestia:getrenderflags()
	for key, value in pairs(flags) do
		flags[key] = false
	end
	celestia:setrenderflags(flags)

	flags = celestia:getlabelflags()
	for key, value in pairs(flags) do
		flags[key] = false
	end
	celestia:setlabelflags(flags)

	flags = celestia:getoverlayelements()
	for key, value in pairs(flags) do
		flags[key] = false
	end
	celestia:setoverlayelements(flags)

	--No need to face away from the Sun: the Sun is not being rendered.
	wait()
end

main()

Is the yellow box around Sol missing one of its corners? If so, fix it by rounding solX and solY to the closest number that is .5 greater than an integer.

solX = math.floor(solX) + .5 solY = math.floor(solY) + .5

Where is the red giant BetelgeuseBetelgeuse (star) in the diagram? What about the white dwarf "Sirius B"Sirius B (star) (one blank, uppercase B)?

What would happen to the diagonal line of the main sequence if the scales of the axes were changed from logarithmic to linear?

Let the main function loop through the three tables of flags with a for loop. We can think of the celestiacelestia (object) object as being a table of functions. Each function receives the celestia object as its first or only argument.

for i, name in ipairs({"renderflag", "labelflag", "overlayelement"}) do local functionName = "get" .. name .. "s" local f = celestia[functionName] assert(type(f) == "function") local flags = f(celestia) for key, value in pairs(flags) do flags[key] = false end functionName = "set" .. name .. "s" f = celestia[functionName] assert(type(f) == "function") f(celestia, flags) end

The stars are grouped into vertical lines because Celestia assigns the same temperature to every star of a stellar class. For example, every star of the Sun’s class G2VG2V (spectral type) has the Sun’s temperature of 5860 Kelvin. Blur out the lines by adding a small random value (positive or negative) to each star’s temperature.

Colorcolor, of star the stars by their temperature. The C++ array StarColors_Blackbody_2deg_D65 in version/src/celengine/starcolors.cpp gives us the rgb color corresponding to a given Kelvin temperature. Create a Celx array containing the same information. The array should be a field of the Lua hook table, initialized when we require("std"). Get a star’s temperature in Kelvin from the star’s info table (infoTable) and look up the corresponding rgb value in the array. See the member function ColorTemperatureTable::lookupColor in version/src/celengine/starcolors.h.

Create your own Iteratoriterator

We have seen five functions that return an iterator: ipairs (arrayOfStrings), pairs (tableOfFields), Object:locations (infoTable), Celestia:stars (loopDatabase), and Celestia:dsos (loopDatabase). Now let’s write our own function that returns an iterator.

The signs in the following program is clearly a function. What is surprising is that signs()—the return value of signs—is also a function. Since the latter function has no name we refer to it as an anonymous functionanonymous function. The anonymous function is called over and over by the generic for loop. Each call returns the next value in a series of values, or nilnil (Lua value) when the series is exhausted. The nil breaks us out of the loop. A function used in this way is referred to as an iterator.

The iterator is created inside the function signs. This allows the iterator mention the local variable(s) of signs, in this case i.

Our simple program calls signs only twice: in the assertion and at the start of the for loop. A more complicated program might call signs many times. Since signs creates and returns a new iterator each time it is called, we say that signs is a factory functionfactory function. A call to the factory creates more than just another copy of the iterator. It also creates another copy of the local variable(s), in this case i, for use by the iterator. Since each iterator has its own copy of i, we could have more than one iterator traversing the array at the same time.

--[[
Create an iterator for looping through a table.
]]
require("std")

local signsTable = {
	"Aries",
	"Taurus",
	"Gemini",
	"Cancer",
	"Leo",
	"Virgo"
}

local function signs()
	local i = 0		--the first key, minus 1

	--Create an iterator and return it.
	--The iterator is an anonymous function.

	return function()
		i = i + 1
		if i <= #signsTable then
			--The iterator returns a pair of values.
			return i, signsTable[i]
		end
	end
end

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	local s = ""
	assert(type(signs) == "function" and type(signs()) == "function")

	--i and sign are the two values returned by each call to the iterator.
	for i, sign in signs() do
		s = s .. i .. " " .. sign .. "\n"
	end

	celestia:print(s, 60, -1, 1, 1, -1)
	wait()
end

main()
1 Aries 2 Taurus 3 Gemini 4 Cancer 5 Leo 6 Virgo

Read the two factories in the standard library: the function std.xyz (simple; see observerSetposition) and the method Object:family (complicated; see Recursion).

Write a factory that returns an iterator that returns all the names of a star or deep-sky object, one by one. See inputFile.

local star = celestia:find("Betelgeuse") local s = "" for name in names(star) do s = s .. name .. "\n" end

For Space CadetsSpace Cadet only: let the factory be a method of class Object.

for name in star:names() do

Write a factory that returns an iterator that returns all the galaxies except a given galaxy, in order of increasing distance from the given galaxy. You’ll have to wait until we do Object:getposition in objectGetposition.

--List the neighbors of the Andromeda Galaxy, starting with the closest. local m31 = celestia:find("M 31") --one space after the M local s = "" for i, neighbor in m31:neighbors() do local v = m31:getposition() - neighbor:getposition() local lightyears = v:length() / 1000000 s = s .. string.format("%-7s %8.1f\n", neighbor:name(), lightyears) if i >= 5 then break end end
M 110 95824.9 And I 164442.8 M 32 190924.3 And III 238377.7 And V 371646.7

Write a factory that returns an iterator that returns all the stars, like Celestia:stars, but relieves the user of having to call wait every 5 seconds while the loop is in progress. Use Celestia:getscripttime.

for star in celestia:carefreestars() do --time consuming calculation, --without worrying about calling wait end

Types of Timetime

Simulation timesimulation time is the time that passes in the Celestia universe. It can be “get” and “set” with Celestia:gettime and settime, and speeded up, slowed down, halted, or reversed with Celestia:settimescale. Simulation time is measured in Earth days. Examples include the time fields of a celestial object’s info table (infoTable): rotationPeriod, orbitPeriod, lifespanStart, and lifespanEnd. The atmosphereCloudSpeed is in radians per day.

Real timereal time is the time that passes in the universe of the user sitting in front of the Celestia application. It cannot be set or accelerated—we just have to wait for it to pass. The current real time is returned by Celestia:getsystemtime and the Lua functions os.date and os.time. Intervals of real time are measured in seconds. For example, Celestia:getscripttime returns the number of seconds of real time since the Celx script started running, and the argument of wait specifies how many seconds until the script runs again. The standard library constants std.logo and std.duration are in seconds of real time. So are the time arguments and return value of the following functions.

  1. Waiting functions: wait and Celestia:settimeslice
  2. Printing methods: Celestia:print, flash, mmprint
  3. Goto methods: Observer:goto, gotodistance, gotolocation, gotolonglat, gotosurface, center, centerorbit
  4. Speed methods: Observer:setspeed and getspeed, in microlightyears per second of real time
  5. Event functions: the tick handler (tickHandler) and the tick Lua hook (luaHookFunctions).

Real Timereal time

The current real time is returned by the method Celestia:getsystemtimeCelestia:getsystemtime in UTCUTC (Coördinated Universal Time), i.e., Greenwich Mean Time. The return value is updated only once per second. (The fractional part of the current second is chopped off by the C language castcast (C language) (int) in the member function astro::Date::systemDate in version/src/celengine/astro.cpp.) The return value is one big number called a Julian dateJulian date, giving the number of days that have elapsed since noon on Monday, January 1, 4713 B.C. The Julian date is currently up in the two millions and climbing, and can include a fraction.

The method Celestia:tdbtoutcCelestia:tdbtoutc breaks out a Julian date into a table with six humanly readable fields: year, month, day, hour, minute, seconds. The first five fields are integers. The last field can have a fraction, which we can chop off with math.floor or round with std.round. The month numbers range from 1 to 12 inclusive, the hours from 0 to 23 inclusive. The following program formats the hours, minutes, and seconds as two-digit numbers, with a leading zero if necessary.

The Lua function os.dateos.date can return the current real time in UTC or the local time zone. It accepts the same % formats% format (os.date) as the C function strftimestrftime (C function). For example, %z is our zone’s number of hours and minutes ahead of UTC. The return vaue is a humanly-readable string.

Three warnings. (1) The table returned by Celestia:tdbtoutc does not have the same field names as the tables passed to os.timeos.time and returned by os.date. (2) The table returned by Celestia:tdbtoutc is in UTC, while the table passed to os.time is in local time. (3) If your times are about 66 seconds ahead of where they should be, you have probably called Celestia:fromjulianday instead of Celestia:tdbtoutc. See stepThrough.

--[[
Display the current real time in UTC and the local time zone,
updated every second.  Microsoft Windows doesn't have the %z format.
]]
require("std")

local function tickHandler()
	local tdb = celestia:getsystemtime()   --tdb is the current Julian date.
	assert(type(tdb) == "number")
	local utc = celestia:tdbtoutc(tdb)
	assert(type(utc) == "table")

	local s = string.format(
		"Real Time:\n"
		.. "%.15g\n"
		.. "%d %.3s %d %02d:%02d:%02d UTC\n"   --only first 3 characters
		.. "%s\n"
		.. "%s",
		tdb,
		utc.year, std.monthName[utc.month], utc.day,
		utc.hour, utc.minute, std.round(utc.seconds),
		os.date("!%Y %b %d %H:%M:%S %Z"),    --exclamation point for UTC
		os.date( "%Y %b %d %H:%M:%S %Z (%z)"))     --no ! for local time

	celestia:print(s, 1, 1, 1, -22, -2) --upper right, below simulation time
end

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	celestia:requestsystemaccess()	--Get permission to mention os.
	wait()

	celestia:registereventhandler("tick", tickHandler)
	wait()
end

main()
Real Time: 2456316.40773362 2013 Jan 23 21:46:02 UTC 2013 Jan 23 21:46:02 UTC 2013 Jan 23 16:46:02 EST (-0500)

Verify that the simulation time runs in sync with the real time by default. Then speed up the simulation time by pressing three lowercase Ls. Observe that this has no effect on the speed of the real time.

The above program displayed the time as a string. Let’s get one of the fields as a numeric value, e.g., the current hour of the day. A string returned by os.date can be converted to a number with the Lua function tonumbertonumber (Lua function).

local hour1 = tonumber(os.date("!%H")) --in UTC local hour2 = tonumber(os.date("%H")) --in local time assert(type(hour1) == "number" and type(hour2) == "number")

Create an array of localizedlocalization month names by calling the Lua functions os.time and os.date.

celestia:requestsystemaccess() --Get permission to mention os. wait() --"fr_CAFrench language" on Mac and other Unixes, "French_Canada" on Microsoft Windows. os.setlocaleos.setlocale("fr_CA") local monthName = {} --Create an empty table. for m = 1, 12 do local t = {year = 2000, month = m, day = 1} table.insert(monthName, os.date("%B", os.time(t))) end

On Macintosh and other Unixes, we can change the environment variable LC_ALLLC_ALL (environment variable) directly instead of calling os.setlocale.

locale echo $LC_ALL export LC_ALL=fr_CA echo $LC_ALL locale Celestia myprog.celx

Or we can change the environment variable only for Celestia:

LC_ALL=fr_CA Celestia myprog.celx

The French “February” is février on Mac, frier on Microsoft Windows. Vive la différence.

1 janvier 2 février 3 mars etc.

Simulation Timesimulation time

Simulation time can be expressed in UTC or TDB. UTCUTC (Coördinated Universal Time) is the Coördinated Universal Time kept by clocks on Earth. TDBTDB (Barycentric Dynamical Time) is the Barycentric Dynamical TimeBarycentric Dynamical Time that would be kept by a clock at the barycenterbarycenter (of Solar System) (center of mass) of the Solar System. TDB is untroubled by the difference in gravitation between mountaintop and ocean floor. It is not dilated by the acceleration of the Earth in its elliptical orbit. And it certainly doesn’t care that the Earth’s rotation is slowing down. Celestia uses TDB for all its internal needs, and UTC (or the local equivalent) only for the display in the upper right corner of the window.

Let’s display the current simulation time in TDB and UTC, updated wth every tick of the simulation The method Celestia:gettimeCelestia:gettime returns the simulation time as a Julian dateJulian date measured in TDB. We can convert it to UTC with Celestia:tdbtoutcCelestia:tdbtoutc.

--[[
Display the current simulation time in UTC and TDB, updated every tick.
]]
require("std")

local function tickHandler()
	local tdb = celestia:gettime()	--Get the Julian date in TDB.
	assert(type(tdb) == "number")

	local utc = celestia:tdbtoutc(tdb)
	assert(type(utc) == "table")

	local s = string.format(
		"Simulation time:\n"
		.. "%.15g\n"
		.. "%d %.3s %d %02d:%02d:%02d UTC",
		tdb,
		utc.year, std.monthName[utc.month], utc.day,
		utc.hour, utc.minute, math.floor(utc.seconds))

	celestia:print(s, 1, 1, 1, -17, -2)	--upper right, below time
end

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)
	celestia:registereventhandler("tick", tickHandler)
	wait()
end

main()
Simulation time: 2456316.51097139 2013 Jan 24 00:14:41 UTC

The inverse of Celestia:tdbtoutc is Celestia:utctotdbCelestia:utctotdb. It takes six arguments (year, month, day, hour, minute, seconds) and boils them back down into a Julian date. The hour, minute, and seconds default to 0, the month and day default to 1, and the year defaults to 0. The fractional part of each argument except the last is ignored.

Celestia:utctotdb provides a user-friendly way to compose the argument of Celestia:settime; see settime.

local originalTdb = celestia:gettime() --Break it down into a table of six numbers. local utc = celestia:tdbtoutc(originalTdb) assert(type(utc) == "table") --Put it back together into one number. local reconstitutedTdb = celestia:utctotdb(utc.year, utc.month, utc.day, utc.hour, utc.minute, utc.seconds) assert(type(reconstitutedTdb) == "number") --utctotdb and tdbtoutc are inverse functions. assert(originalTdb == reconstitutedTdb)

Set the Time and the Timescale

We may regard the present state of the universe as the effect of its past and the cause of its future. An intelligence which at a certain moment would know all forces that set nature in motion, and all positions of all items of which nature is composed—an intelligence vast enough to submit these data to analysis—would embrace in a single formula the movements of the greatest bodies of the universe and those of the tiniest atom. For such an intelligence nothing would be uncertain, and the future just like the past would be present before its eyes.
Pierre-Simon LaplaceLaplace, Pierre-Simon, A Philosophical Essay on ProbabilitiesPhilosophical Essay on Probabilities, A (Laplace) (1814)

Let’s watch the shadow of the Moon travel across Europe during the total solar eclipseeclipse, solar of August 11, 1999. We will go to the start of the eclipse, fast forward through it at 600 times the normal speed, and revert to the original speed when it is over.

It’s easy to go to the start of the eclipse with Celestia:settimeCelestia:settime, and speed up the time with Celestia:settimescaleCelestia:settimescale. But it’s harder to go back to the original time scale at the end of the eclipse. We’ll use a tick handler (tickHandler) to detect when the current time has reached the end of the eclipse. The tick handler will tell Celestia to stop calling the tick handler, and will set the time scale back to 1. To avoid unnecessary work, we create the variable t1 once and for all as a global rather than re-creating it every time the tickHandler is called.

Our vantage point will be four Earth radii above the subsolar pointsubsolar point—the point on the Earth where the Sun is directly overhead. A full explanation of how we keep the observer suspended there is in lockFrame. For now, we will create a moving “frame of reference” whose X axis always points from the Earth to the Sun. We keep the observer positioned on this axis, looking at the Earth, and oriented so that the universal Y axis (universalFrame) is pointing upwards.

In totality we will draw a line on the surface of the Earth along the path of totality.

--[[
Fast forward through a total solar eclipse at 600 times faster than normal
speed, then revert to the original speed.
Face the Earth from a point on the Earth-Sun line, with the north pole of the
ecliptic towards the top of the window.
]]
require("std")

--variable used by tickHandler:
local t1 = celestia:utctotdb(1999, 8, 11, 13, 50, 0.0)	--end of eclipse

local function tickHandler()
	if celestia:gettime() >= t1 then
		--Stop calling the tickHandler.
		celestia:registereventhandler("tick", nil)
		--Go back to the normal speed.
		celestia:settimescale(1)
	end
end

local function main()
	local observer = celestia:sane()
	celestia:hidelabel("planets")	--We know that the Earth is the Earth.

	--The International Space Station is a distracting dot.
	local iss = celestia:find("ISS")
	iss:setvisible(false)

	--Field of view of 30 degrees is wide enough to see the whole Earth.
	observer:setfov(math.rad(30))

	local sol = celestia:find("Sol")
	local earth = celestia:find("Sol/Earth")
	local lockFrame = celestia:newframe("lock", earth, sol)
	observer:setframe(lockFrame)	--Adhere the observer to the frame.

	local microlightyears = 5 * earth:radius() / KM_PER_MICROLY
	local position = celestia:newposition(microlightyears, 0, 0)
	observer:setposition(lockFrame:from(position))
	observer:lookat(earth:getposition(), std.yaxis)

	--a few minutes before total solar eclipse begins
	local t0 = celestia:utctotdb(1999, 8, 11, 8, 10, 0.0)
	celestia:settime(t0)

	--Start the fast-forward as soon as the "CELESTIA" logo disappears.
	wait(std.logo)

	--Fast forward one hour of simulation time per 6 seconds of real time.
	celestia:settimescale(60 * 60 / 6)
	celestia:registereventhandler("tick", tickHandler)
	wait()
end

main()

Instead of hardcoding the angle of the field of view into the above program, derive it from the observer’s distance from the Earth. See trigonometryFun.

--observer's distance from center of Earth, in Earth radii local distance = 5 local angularRadius = math.asinmath.asin(1 / distance) --of Earth, in radians local margin = 1 --in degrees. Two margins, at top and bottom. observer:setfov(2 * (angularRadius + math.rad(margin)))

The fast-forward interval has an abrupt start and end. After you have read gradualStart, come back to the above program and have the Earth pick up speed gradually.

Demonstrate that the simulation time does not advance until we call waitwait (wait).

--[[
Demonstrate that the simulation time does not advance until we call wait.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	local s0 = celestia:getscripttime()	--real time in seconds
	local t0 = celestia:gettime()		--simulation time in days

	--Noticeable delay, but not long enough to require a call to wait.
	for i = 1, 2^27 do		--134,217,728
	end

	local s = string.format(
		"The loop took\n"
		.. "%.15g seconds of real time\n"
		.. "%.15g seconds of simulation time",
		celestia:getscripttime() - s0,
		(celestia:gettime() - t0) * 24 * 60 * 60)

	celestia:print(s, 60)
	wait()
end

main()
The loop took 1.13859009742737 seconds of real time 0 seconds of simulation time

Also demonstrate that the simulation time in Celestia is clamped to the range two billion BC to two billion AD (at noon on January 1 of each year), even though the UniverseUniverse, age of is almost 14 billion years old. Be sure to call wait after calling Celestia:settime; what happens if you don’t? Warning: an integer greater than or equal to 231 = 2,147,483,648 must be formatted with "%.0f"; see integerFormatting.

Time Dilationtime dilation

The methods Celestia:tdbtoutcCelestia:tdbtoutc and Celestia:utctotdbCelestia:utctotdb know that UTC speeds up and slows down relative to TDB. UTC passes slowly when the Earth is moving quickly (in early January, at perihelionperihelion (of Earth)—the Earth’s closest approach to the Sun). A January UTC day is therefore longer than a TDB day. UTC passes quickly when the Earth is moving slowly (in early July, at aphelionaphelion (of Earth)—the Earth’s farthest retreat from the Sun). A July UTC day is shorter than a TDB day.

The plus sign in the format %+.15g prints a leading plus or minus.

--[[
Print the average length in TDB days of a UTC day at various times of the year.
We'll print the average of the first n UTC days of each month.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	local year = celestia:tdbtoutc(celestia:gettime()).year	--current year
	local n = 10	--average the first n days of each month
	local s = ""

	for month = 1, #std.monthName do
		--average length of a UTC day, measured in TDB days.
		local d = (celestia:utctotdb(year, month, n + 1)
		         - celestia:utctotdb(year, month, 1)) / n

		s = s .. string.format(
			"%.2s: 1 UTC day = %17.15f TDB days = 1 day",
			std.monthName[month], d)

		if d ~= 1 then
			s = s .. string.format(" %+.15g seconds",
				(d - 1) * 24 * 60 * 60)
		end
		s = s .. "\n"
	end

	celestia:print(s, 60, -1, 1, 1, -1)
	wait()
end

main()

The 2.81631869825105e-05 in the first line of output stands for 2.81631869825105 · 10–5 = .0000281631869825105

Ja: 1 UTC day = 1.000000000325963 TDB days = 1 day +2.81631869825105e-05 seconds Fe: 1 UTC day = 1.000000000279397 TDB days = 1 day +2.41398772971024e-05 seconds Ma: 1 UTC day = 1.000000000186265 TDB days = 1 day +1.60932579262862e-05 seconds Ap: 1 UTC day = 1.000000000000000 TDB days = 1 day Ma: 1 UTC day = 0.999999999813735 TDB days = 1 day -1.60932579262862e-05 seconds Ju: 1 UTC day = 0.999999999720603 TDB days = 1 day -2.41398772971024e-05 seconds Ju: 1 UTC day = 0.999999999674037 TDB days = 1 day -2.81631965748375e-05 seconds Au: 1 UTC day = 0.999999999720603 TDB days = 1 day -2.41398772971024e-05 seconds Se: 1 UTC day = 0.999999999813735 TDB days = 1 day -1.60932579262862e-05 seconds Oc: 1 UTC day = 1.000000000000000 TDB days = 1 day No: 1 UTC day = 1.000000000186265 TDB days = 1 day +1.60932579262862e-05 seconds De: 1 UTC day = 1.000000000325963 TDB days = 1 day +2.81631869825105e-05 seconds

Thus a UTC day can be more than 10–5 seconds longer than a TDB day. And it adds up. A hundred UTC days can be 10–3 seconds longer than a hundred TDB days.

Leap Secondsleap second

We have seen that Celestia:tdbtoutcCelestia:tdbtoutc and Celestia:utctotdbCelestia:utctotdb know that UTC is slowed down and speeded up by the Earth’s orbit. They also know that UTC is occasionally delayed by a leap second. For example, they know that the interval between the following times is two seconds. 11:59:59 p.m. December 31, 2008 UTC
12:00:00 a.m. January 1, 2009 UTC

A parallel pair of methods, Celestia:fromjuliandayCelestia:fromjulianday and Celestia:tojuliandayCelestia:tojulianday, takes a less sophisticated view of time. They remain rigid in their belief that every minute has 60 seconds. They refuse to acknowledge that the Earth’s rotation is slowing down. They reject Einsteinian time dilation. A sure-fire way to get the 66-second error in tdb is to pass the return value of Celestia:tojulianday to Celestia:settime, or the return value of Celestia:gettime to Celestia:fromjulianday.

know about time dilation
and leap seconds
don’t know about time dilation
and leap seconds
convert number to table Celestia:utctotdb Celestia:fromjulianday
convert table to number Celestia:tdbtoutc Celestia:tojulianday

The following subtractions give us the interval in days between the times shown above. We multiply the intervals by 60 · 60 · 24 to convert them to seconds. The %.4f rounds the answer to the nearest ten-thousandth.

--[[
Demonstrate that Celestia:utctotdb knows about leap seconds, and that
Celestia::tojulianday doesn't, by printing the distance in seconds between
23:59:59 December 31, 2008
00:00:00 January   1, 2009
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	local tdb0 =    celestia:utctotdb(2008, 12, 31, 23, 59, 59)
	local tdb1 =    celestia:utctotdb(2009,  1,  1,  0,  0,  0)

	local utc0 = celestia:tojulianday(2008, 12, 31, 23, 59, 59)
	local utc1 = celestia:tojulianday(2009,  1,  1,  0,  0,  0)

	local s = string.format(
		"Distance according to utctotdb:    %.4g second(s)\n"
		.. "Distance according to tojulianday: %.4g second(s)",
		(tdb1 - tdb0) * 60 * 60 * 24,
		(utc1 - utc0) * 60 * 60 * 24)

	celestia:print(s, 10)
	wait()
end

main()
Distance according to utctotdb: 2 second(s) Distance according to tojulianday: 1 second(s)

Step Through the Calendarcalendar

brady. In fact, he determined that the Lord began the Creation on the 23rd of October in the year 4,004 b.c. at—uh, at 9 a.m.!

drummond. That Eastern Standard Time? (Laughter) Or Rocky Mountain Time? (More laughter) It wasn’t daylight-saving time, was it? Because the Lord didn’t make the sun until the fourth day!

Jerome LawrenceLawrence, Jerome and Robert E. LeeLee, Robert E. (playwright), Inherit the WindInherit the Wind (1955)

The simpleminded pair Celestia:fromjuliandayCelestia:fromjulianday and Celestia:tojuliandayCelestia:tojulianday still have a modest but essential rôle to play. Precisely because of their belief in the 60-second minute, they provide a uniform way to step through the calendar. Celestia:tojulianday follows two cast-iron rules, reminiscent of the Peano axiomsPeano axioms (of arithmetic):

  1. It converts UTC noon of January 1, 4713 B.C. into zero. (Since there was no year 0 A.D., the first argument of this call to Celestia:tojulianday has to be –4712.)
  2. Adding or subtracting a number of days (possibly with a fraction) increases or decreases the return value by the same number. For example, it converts UTC noon of consecutive days into consecutive whole numbers.

Let’s find the day of the week of the current simulation time, even though we could easily get it from os.timeos.time and os.dateos.date. Since the first Julian day started at noon, we pass noon of the current day to Celestia:tojulianday. The variable std.dayNamestd.dayName is an array of seven strings, starting with "Monday" because the first Julian date, January 1, 4713 B.C., would have been a Monday if our calendar went back that far. The expression #std.dayName has the value 7. The operator %% (remainder operator) divides d by 7 and gives us a remainder in the range 0 to 6 inclusive. Adding 1 gives us a sum in the range 1 to 7 inclusive, which is used as the array subscript.

--[[
Print the day of the week of the current simulation time.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	--Get permission to mention os.
	celestia:requestsystemaccess()
	wait()

	local tdb = celestia:gettime()
	local utc = celestia:tdbtoutc(tdb)
	local d = celestia:tojulianday(utc.year, utc.month, utc.day, 12)
	local tab = {year = utc.year, month = utc.month, day = utc.day}

	local s = string.format(
		"Julian date %d\n"
		.. "%s %d/%d/%d\n"
		.. "%s",
		d, std.dayName[d % #std.dayName + 1],
		utc.month, utc.day, utc.year,
		os.date("%A %m/%d/%Y", os.time(tab)))

	celestia:print(s, 60)
	wait()
end

main()
Julian date 2456318 Friday 1/25/2013 Friday 01/25/2013

Get the localizedlocalization names of the weekdays from os.time and os.date as in realTime.

Lundi Mardi Mercredi etc.

Let’s step through the calendar and find the days that had a leap second. We will call Celestia:tojulianday to combine the current year, month, and day to a single number. This will be the initial value of the induction variableinduction variable (of loop) of the loop—the variable that the loop counts with. During each iteration, Celestia:fromjulianday breaks the number back down into separate values for year, month, and day.

The distance from midnight UTC to the next midnight UTC, tdb1 – tdb0, is usually one day. If the number of days is closer to 1 + 1 60 · 60 · 24 there must have been a leap second.

--[[
List the days that had a leap second.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	--The year of the current simulation time
	local year = celestia:tdbtoutc(celestia:gettime()).year

	local first = celestia:tojulianday(1972,  1,  1, 12)
	local last  = celestia:tojulianday(year, 12, 31, 12)

	local s = ""
	for day = first, last do
		local utc0 = celestia:fromjulianday(day)
		local utc1 = celestia:fromjulianday(day + 1)

		local tdb0 = celestia:utctotdb(utc0.year, utc0.month, utc0.day)
		local tdb1 = celestia:utctotdb(utc1.year, utc1.month, utc1.day)

		if tdb1 - tdb0 > 1 + .5 / (60 * 60 * 24) then
			s = s .. string.format("%d/%d/%d\n",
				utc0.month, utc0.day, utc0.year)
		end
	end

	celestia:print(s, 60, -1, 1, 1, -1)
	wait()
end

main()

The last leap second Celestia currently knows about was in 2008.

6/30/1972 12/31/1972 12/31/1973 etc. 12/31/1998 12/31/2005 12/31/2008

The return value of Celestia:fromjulianday is ahead of the return value of Celestia:tdbtoutc because the former does not know that the Earth’s rotation is slowing down. Let’s find out how far ahead it is. Put the following code into a tickHandler.

local tdb = celestia:gettime() local utc1 = celestia:fromjulianday(tdb) local utc2 = celestia:tdbtoutc(tdb) local difference = 60 * (utc1.minute - utc2.minute) + utc1.seconds - utc2.seconds local s = string.format( "difference = %.15g seconds\n" .. "difference - 34 - 32.184 = %.15g seconds", difference, difference - 34 - 32.184) difference = 66.1846742033958 seconds difference - 34 - 32.184 = 0.000674203395846007 seconds

The 66.1846742033958 seconds is the sum of three terms:

  1. UTC is currently (as of January 2013) 35 seconds behind International Atomic TimeInternational Atomic Time (TAITAI (International Atomic Time)), because the equivalent of 35 leap seconds have been inserted into the calendar since some arbitrary starting point. Celestia knows about 34 of them; the list in version/src/celengine/astro.cpp ends in 2009.
  2. TAI, in turn, is a constant 32.184 seconds behind Terrestrial TimeTerrestrial Time (TTTT (Terrestrial Time)); see the constant dTA in astro.cpp. The reasons for this offset go back to the work of Simon Newcomb in the Nineteenth Century.
  3. Finally, TT stays within .002 seconds of TDBBarycentric Dynamical Time, shifting around due to the relativistic effectstime dilation as the Earth orbits the Sun.

What is the value of the difference when the Earth is at perihelionperihelion (of Earth) in January and and aphelionaphelion (of Earth) in July? What about in April and October? Was the difference any smaller 10 years ago?

We’ll miss you for the holidays, this year they’re coming later—
We hope you have a very lovely seder in your crater.
Allan ShermanSherman, Allan, Shine On, Harvey BloomShine On, Harvey Bloom (Allan Sherman) (1964)

4713 B.C. was chosen as the start of the JulianJulian date numbering system because it was the most recent year that was simultaneously the first year of the solar, Metonic, and indiction cycles.

  1. The solar cyclesolar cycle (calendar) is the 28-year period during which the weekdays of the calendar repeat themselves. For example, the years 2000 and 2028 had a January 1st that fell on the same day of the week (it happened to be Saturday). The cycle is disrupted by the exceptional non-leap years 1700, 1800, and 1900, and triggers the Jewish liturgy of the Birkat HachamaBirkat Hachama (Jewish liturgy).
  2. The Metonic cycleMetonic cycle is the 19-year period during which the calendar days of the phases of the moon repeat themselves, give or take a few hours. For example, the years 2000 and 2019 had a January full moon on the same day of the month (it happened to be the 19th). The Metonic cycle governs the Jewish calendar: 7 out of every 19 years are leap years.
  3. The indiction cycleindiction cycle (Roman taxation) was a 15-year tax period in the RomanRoman Empire and ByzantineByzantine Empire empires.

The monk Dionysius ExiguusDionysius Exiguus said that JesusJesus, birth of was born in a year that was ninth in the solar cycle, first in the Metonic cycle, and third in the indiction cycle. Since he was the inventor of the Anno DominiAnno Domini numbering system, we’ll take his word for it. A thousand years later another scholar, Joseph Justus ScaligerScalinger, Joseph Justus, counted backwards from the birth of Jesus until he found a year that was first in all three cycles. He designated that year as the start of the Julian Period.

--[[
Count backwards from the birth of Jesus to the most recent year that was first
in all three cycles: solar, Metonic, and indiction.  That year is where the
Julian dates begin.
]]
require("std")

local function main()
	local observer = celestia:sane()
	observer:setposition(std.position)

	--Dionysius Exiguus said Jesus was born in a year that was 9 in the
	--solar cycle, 1 in the Metonic cycle, and 3 in the indiction cycle.
	local solar = 9		--28-year solar cycle
	local metonic = 1	--19-year Metonic cycle (golden numbers)
	local indiction = 3	--15-year induction cycle

	local year = 0
	while solar ~= 1 or metonic ~= 1 or indiction ~= 1 do
		year = year - 1	--Go back one year.

		if solar == 1 then
			solar = 28
		else
			solar = solar - 1
		end

		if metonic == 1 then
			metonic = 19
		else
			metonic = metonic - 1
		end

		if indiction == 1 then
			indiction = 15
		else
			indiction = indiction - 1
		end
	end

	--Arrive here when solar, metonic, and indiction are all 1.
	local utc = celestia:fromjulianday(0)
	local s = string.format(
		"Year %d was the first year of all three cycles.\n"
		.. "Julian date 0 is %s %d, %d %02d:%02d:%02d UTC.",
		year,
		std.monthName[utc.month], utc.day, utc.year,
		utc.hour, utc.minute, utc.seconds)

	celestia:print(s, 60)
	wait()
end

main()
Year -4712 was the first year of all three cycles. Julian date 0 is January 1, -4712 12:00:00 UTC.

Solar Days and Synodic Months

An average rotationEarth, rotation of rotation of the Earth with respect to the Sun—i.e., an average day and night—is called a mean solar daymean solar day and takes 24 hours. But a rotation of the Earth with respect to the UniverseUniverse and sidereal time—i.e., with respect to the “universal frame” in universalFrame—is called a sidereal daysidereal day and takes approximately 23 hours and 56 minutes. The exact value is stored in the Earth’s info table as a rotationPeriodrotationPeriod (field of info table) of 0.997269558333333 mean solar days. How could we have calculated that the mean solar day is four minutes longer than the sidereal day?

The angular distance in radians through which the Earth rotates with respect to the universal frame in t days is f(t) = 2π t rotationPeriod For example, in one rotationPeriod the Earth makes one full rotation of 2π radians because f(rotationPeriod) = 2π

Similarly, the angular distance in radians through which the Earth revolves around the Sun in t days is g(t) = 2π t orbitPeriod where the orbitPeriodorbitPeriod (field of info table) of 365.25 mean solar days also comes from the Earth’s info table. For example, in one orbitPeriod the Earth revolves a full 2π radians because

g(orbitPeriod) = 2π

In the following diagram, the little person on the bottom Earth is experiencing noon because the Sun is directly above his or her head. Let’s say this happens at time t = 0. If the Earth remained in the same place with respect to the Sun, the next noon at the point where he or she is standing would come when f(t) = 2π i.e., when the point has rotated all the way around the Earth. Solving the above equation for t yields t = rotationPeriod and the Earth is portrayed at this time in the middle picture.

But because of the Earth’s revolution around the Sun, the next noon actually comes a bit later. It happens when f(t) = 2π + g(t) where g(t) is the angle through which the Earth has revolved around the Sun since the starting time. Solving the above equation for t yields a value slightly longer than the rotationPeriod: t = rotationPeriod · orbitPeriod orbitPeriod - rotationPeriod and the Earth is portrayed at this time in the top diagram.

The value of t is the length of the mean solar day. We can solve for t only if the rotationPeriod and the orbitPeriod are unequal. Were they equal, the Earth would always keep the same side facing the Sun, as the Moon always keeps the same side facing the Earth. The next noon would never come and the program would divide by zerodivision by zero.

Let’s find the meanSolarDay:

--[[
Print the length of Earth's mean solar day.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	local info = celestia:find("Sol/Earth"):getinfo()
	local rotation = info.rotationPeriod
	local orbit = info.orbitPeriod
	assert(orbit > rotation)

	--Measured in mean solar days, so tab[3][2] should be 1.
	local tab = {
		{"sidereal year",  orbit},
		{"sidereal day",   rotation},
		{"mean solar day", rotation * orbit / (orbit - rotation)}
	}

	local s = ""
	for i, value in ipairs(tab) do
		--Convert days to days, hours, minutes, seconds.
		local dhms = std.todhms(value[2])

		s = s .. string.format(
			"%-14s = %17.15g days = %3dd %2dh %2dm %.15gs\n",
			value[1], value[2],
			dhms.days, dhms.hours, dhms.minutes, dhms.seconds)
	end

	celestia:print(s, 60)
	wait()
end

main()
sidereal year = 365.25 days = 365d 6h 0m 0ss sidereal day = 0.997269558333333 days = 0d 23h 56m 4.08984000000402s mean solar day = 0.999999933396747 days = 0d 23h 59m 59.9942454789675s

Verify that a mean solar day on MercuryMercury, mean solar day of is approximately 176 Earth mean solar days.

It takes the MoonMoon, synodic period of approximately 27 days to orbit the Earth (a sidereal monthsidereal month), measured with respect to the universal frame. Use the same formula to predict how many days it will take the Moon to cycle through its phasesphases, of Moon as seen from the Earth (a synodic monthsynodic month):

local earth = celestia:find("Sol/Earth"):getinfo().orbitPeriod local moon = celestia:find("Sol/Earth/Moon"):getinfo().orbitPeriod assert(earth > moon) local synodicMonth = earth * moon / (earth - moon) sidereal year = 365.25 days = 365d 6h 0m 0s sidereal month = 27.321661 days = 27d 7h 43m 11.5103999999019s synodic month = 29.530629806842 days = 29d 12h 44m 6.41531114669988s

Use the same formula to predict how long it will take VenusVenus, synodic period of to cycle through its phasesphases, of Venus as seen from the Earth (a synodic periodsynodic period):

local earth = celestia:find("Sol/Earth"):getinfo().orbitPeriod local venus = celestia:find("Sol/Venus"):getinfo().orbitPeriod assert(earth > venus) local synodicPeriod = earth * venus / (earth - venus) Seen from the Earth, it takes 583.94438669439 Earth days for Venus to go through its phases.

MarsMars, synodic period of is at oppositionopposition when the Sun and Mars are in opposite directions as seen from the Earth. At this time, the Earth is directly between them. How far apart in Earth days are two successive oppositions of Mars? In other words, how long does it take Mars to cycle through its phasesphases, of Mars as seen from the Earth? Hint: the Earth is Mars’s Venus.

Why do the tidestides circle the Earth every 24 hours and 50 minutes? (They actually circle twice during this period, but let’s ignore that.) Hint: get the Earth’s rotationPeriod and the Moon’s orbitPeriod.

At midnight, the hour and minute hands of a clockclock, hands of point in the same direction. The next time this will happen is a little after 1:05 a.m. Find the exact time.

How long is a mean solar day on VenusVenus, mean solar day of? Since its rotation is retrograde, you will have to invent a new formula.

The Phasesphase (of object’s lifetime) of a Celestial Object’s Lifetime

A solar system object can go through one or more phases, each with its own shape and appearance. For example, a spacecraft can shed a module, emit an exhaust, develop a bow shock, and pop out a parachute. Let’s take the phases of the HuygensHuygens (spacecraft) spacecraft, listed in the file version/extras-standard/cassini/cassini.ssccassini.ssc (file). During phase 1 it was attached to its mother ship CassiniCassini (spacecraft). During phase 2 it was flying free towards SaturnSaturn, as primary of Titan’s moon TitanTitan (moon of Saturn).

We will begin at a point in time just before the end of phase 1. The method Object:phasesObject:phases is a factory function that returns an iterator that returns an object of class PhasePhase (class). Since we are interested in only the first phase, we will call the iterator only once. t0 and t1 are the times of the start and the end of the phase. I wanted to name them start and end, but endend (Lua keyword) was a Lua keywordkeyword (Lua).

See bodyfixedFrame for the fine points of the “bodyfixed” axesbodyfixed frame of reference. Our present concern is with time, not geometry. Let’s just say that Huygens departs from Cassini along the negative X axis of Cassini’s bodyfixed frame. We assume the Celestia window is landscape, so we oriented the observer to make this axis horizontal to keep Huygens in view as long as possible.

Cassini is shaped roughly like a cylinder, and the axis of this cylinder is Cassini’s Y axis. Our direction of view is perpendicular to this axis to give us a broadside view of Cassini’s body.

Thus Cassini’s X and Y axes lie in the plane of the Celestia window. These conditions make it ideal for the observer to be on Cassini’s Z axis. We place him on the negative Z axis to get slightly better lighting (the phase anglephase angle is less than 90°). To sum up, Cassini’s bodyfixed X axis points left, its Y axis points up, and its Z axis points away from the user into the window. Huygens moves off horizontally to the right.

--[[
Watch Huygens separating from Cassini at the end of the first phase
of Huygen's life.
]]
require("std")

local function main()
	local observer = celestia:sane()
	--Make it look realistic.
	celestia:hide("constellations", "grid")
	celestia:hidelabel("constellations", "globulars", "stars")

	local huygens = celestia:find("Sol/Cassini/Huygens")
	local iterator = huygens:phases()
	assert(type(iterator) == "function")

	local phase = iterator()   --First call to iterator returns first phase.
	assert(type(phase) == "userdata" and tostring(phase) == "[Phase]")

	local t0, t1 = phase:timespan()
	assert(type(t0) == "number" and type(t1) == "number")

	--Huygens will separate 3 seconds after the CELESTIA logo disappears.
	local seconds = std.logo + 3
	local days = seconds / (24 * 60 * 60)
	celestia:settime(t1 - days)

	local cassini = celestia:find("Sol/Cassini")
	celestia:select(cassini)
	local bodyfixedFrame = celestia:newframe("bodyfixed", cassini)
	observer:setframe(bodyfixedFrame)

	local microlightyears = 2 * cassini:radius() / KM_PER_MICROLY
	local position = celestia:newposition(0, 0, -microlightyears)
	observer:setposition(bodyfixedFrame:from(position))

	--Cassini Y axis points towards top of window.
	observer:lookat(cassini:getposition(), bodyfixedFrame:from(std.yaxis))
	wait()
end

main()

In its free-flying phase, Huygens should rotaterotation (in .ssc file) at the “7.5 revolutions per minute” advertised in the comment in cassini.ssc. The PeriodPeriod (of UniformRotation) of the UniformRotationUniformRotation (in .ssc file) in that file is measured in rotations per hour, so change it from 0.125 to .00222222222222222 = 1 / (7.5 · 60) and restart Celestia. The three red marks on Huygens are 120° apart.

A C programmerC (programming language) has a rage to do everything in a single expression. If you share this passion, telescope the creation of t1 to the following.

local t0, t1 = celestia:find("Sol/Cassini/Huygens"):phases()():timespan()

Want to see Saturn too? The X axis of the “lock frame” (lockFrame) points from Cassini to Saturn. The Z axis points upwards, perpendicular to the plane of Cassini’s motion with respect to Saturn. Press lowercase L several times to speed up the simulation time.

celestia:hide("ecliptic", "moons") celestia:hidelabel("planets", "moons") local saturn = celestia:find("Sol/Saturn") local lockFrame = celestia:newframe("lock", cassini, saturn) observer:setframe(lockFrame) local microlightyears = 2 * cassini:radius() / KM_PER_MICROLY local position = celestia:newposition(-microlightyears, 0, microlightyears / 5) observer:setposition(lockFrame:from(position)) observer:lookat(cassini:getposition(), lockFrame:from(std.zaxis))

Spherical Astronomyspherical astronomy

Imagination completed what mere sight could not achieve. Looking down, I seemed to see through a transparent planet, through heather and solid rock, through the buried graveyards of vanished species, down through the molten flow of basalt, and on into the Earth’s core of iron; then on again, still seemingly downwards, through the southern strata to the southern ocean and lands, past the roots of gum trees and the feet of the inverted antipodeans, through their blue, sun-pierced awning of day, and out into the eternal night, where sun and stars are together. For there, dizzyingly far below me, like fishes in the depth of a lake, lay the nether constellations. The two domes of the sky were fused into one hollow spherecelestial sphere, star-peopled, black, even beside the blinding sun. The young moon was a curve of incandescent wire. The completed hoop of the Milky Way encircled the universe.
Olaf StapledonStapledon, Olaf, Star MakerStar Maker (Stapledon) (1937)

This section is a tutorial on spherical astronomy, not on Celx programming. To help the reader visualize the Universe in several frames of reference, we take the liberty of using various features of Celx without explanation. If the code and exercises are somewhat mysterious, don’t worry: we can come back to them later. Just run the programs, look at the window, and drag on the sky when applicable.

The Celestial Dome and Altazimuth Coördinatesaltazimuth coördinates

Like RousseauRousseau, Jean-Jacques, his sources of truth and understanding were to be nature and his own sentient self, preferably put into direct communion with each other, the better to grasp at the Infinite. But where Rousseau had run away from Geneva toward France in search of revelation, SenancourSenancour, Étienne Pivert de ran in the opposite direction.…

Sure enough, the Infiniteinfinity showed up at around fifteen thousand feet …
Simon SchamaSchama, Simon, Landscape and MemoryLandscape and Memory (Schama) (1995)

The Sun and planets, stars and galaxies are located at wildly different distances from an observer on the Earth. But our perception of depth does not extend out to these astronomical ranges. The objects in the sky appear to lie at the same distance from us, forming the surface of a dome or hemispherecelestial hemisphere.

To point out a star or planet in the sky, it is useful to give the dome a grid of latitude and longitude. There are several systems of coördinates we can use. The simplest one takes the zenithzenith (the highest point in the sky) as its North Pole. The latitude in this system is called altitudealtitude (vs. azimuth) and is measured up from the horizon. The longitude is called azimuthazimuth and is measured clockwise (i.e., to the right) from the point on the horizon due north of the observer. The Sun rising in the east, for example, would be at altitude and azimuth 90°. The zenith itself is at altitude 90° and an irrelevant azimuth. The nadirnadir (the point directly below us) is at altitude –90°, except that we’d need a transparent Earth to see it.

Our definition of azimuth is not quite complete yet. At the Earth’s North Pole, there is no point on the horizon due north of the observer. Even worse, at the South Pole every point on the horizon is north of the observer. We therefore stipulate that azimuth at the North Pole shall be measured clockwise from the meridian of 180° East, and azimuth at the South Pole measured clockwise from the Prime Meridian. This ensures that a person walking up or down the Prime Meridian will experience no sudden change in the azimuth of a celestial object when he or she arrives at a pole.

The following program displays an altazimuth coördinate grid, and the altitude and azimuth of the point towards which the observer is facing. He is positioned at the top of the atmosphereatmosphere, position observer at top of to keep the Universe visible even during the day. He initially faces the north point on the horizon with the zenith overhead. For an explanation of the altazimuth machinery, see altazimuthCoordinates. We choose a date and time that puts a familiar constellation right above the north point. We also activate altazimuth mode, in which the down and up arrow keys pitch the view vertically, and the left and right arrows yaw the view horizontally.

--[[
Display the altitude and azimuth of the direction in which the observer is
looking.  To change his direction, press the arrows or drag on the sky.
]]
require("std")

--variables used by tickHandler:
local observer = celestia:sane()
local earth = celestia:find("Sol/Earth")

--New York City
local latitude  = math.rad(  40 + (39 + 51 / 60) / 60 )	--north is positive
local longitude = math.rad(-(73 + (56 + 19 / 60) / 60))	--west is negative

--[[
The origin of the altazFrame is the center of the Earth,
just like the origin of the Earth's bodyfixed frame.
The XZ plane of the altazFrame is parallel to the plane of the horizon at NYC.
The X axis is parallel to the vector pointing east from NYC.
The Z axis is parallel to the vector pointing south from NYC.
The Y axis points from the origin through NYC.
]]
local rotation = celestia:newrotationaltaz(longitude, latitude)
local altazFrame = celestia:newframe("bodyfixed", earth, rotation)

local function tickHandler()
	--Get the observer's forward vector in universal frame coordinates.
	--It points in the direction in which he is looking.
	local orientation = observer:getorientation()
	local forward = orientation:transform(std.forward)

	--Rewrite the vector in altazimuth coordinates.
	forward = altazFrame:to(forward)

	--Convert cartesian altazimuth coordinates to spherical.
	local altaz = forward:getaltaz()

	local s = string.format(
		"Altitude %6.2f%s\n" ..	--2 digits to right of decimal point
		"Azimuth  %6.2f%s",
		math.deg(altaz.altitude), std.char.degree,
		math.deg(altaz.azimuth), std.char.degree)

	celestia:mmprint(s)	--Mark the center of the window with "MM".
end

local function main()
	celestia:hide("grid")	--Hide the right ascension and declination.
	celestia:show("horizontalgrid"horizontalgrid (renderflag))	--altazimuth grid
	celestia:setaltazimuthmode(true)
	celestia:select(earth)

	--Ripe, low-slung Big Dipper above daylit northern horizon.
	local year = celestia:tdbtoutc(celestia:gettime()).year	--current year
	celestia:settime(celestia:utctotdb(year, 1, 29, 20, 23, 0.0))

	observer:setframe(altazFrame)

	--Top of the atmosphere, so we don't have to worry about day and night.
	local kilometers = earth:radius() + earth:getinfo().atmosphereHeight
	local microlightyears = kilometers / KM_PER_MICROLY
	local position = celestia:newposition(0, microlightyears, 0)
	observer:setposition(altazFrame:from(position))

	--Look straight north along the horizon, zenith overhead.
	local forward = celestia:newvectoraltaz(math.rad(0), math.rad(0))
	local up = celestia:newvectoraltaz(math.rad(90), math.rad(0))
	local orientation = celestia:newrotationforwardup(forward, up)
	observer:setorientation(altazFrame:from(orientation))

	celestia:registereventhandler("tick", tickHandler)
	wait()
end

main()
MM Altitude -0.00° Azimuth 0.00°

Verify that the observer is above New York City. Press the up arrow to face straight down to the nadir (altitude –90°). Then press EndEnd key to move far enough away from the Earth to see the Atlantic seaboard. Come back to Earth with HomeHome key.

Verify that the altitude of the North StarNorth Star, altitude of in the sky is equal to the latitude of the observer on the Earth. Since he is above New York City at approximately 40° North, you can press the down arrow to pitch his view up to altitude 40°. Then press three lowercase Ls to speed up time, and three lowercase Ks to slow it back down.

Try another city, or another latitude and longitude. Is the horizontal grid rendered correctly when the observer is directly above a pole? See the member function Renderer::renderSkyGrids in version/src/celengine/render.cpp.

What is the azimuth of the rising sun on the first day of spring at sea level in New York City? Sunrise on that date is approximately 6:00 UTC at Greenwich, and 1/360 of a day later for each degree of longitude west of there. In New York, for example, the sun rises 74/360 of a day later. celestia:settime(celestia:utctotdb(year, 3, 21, 6) + 74/360) The surface of the Earth jittersjitter (of planetary surface) annoyingly at sea level, so position the observer a few meters higher: local kilometers = earth:radius() + .01 --10 meters above sea level Look East by facing towards azimuth 90°. local forward = celestia:newvectoraltaz(math.rad(0), math.rad(90)) Press lowercase L to fast-forward to the exact moment of sunrise, the freeze the simulation time with the spacebar. In altazCelobject, we’ll get the altitude and azimuth of a celestial object without the need for trial and error. In whenSunset, we’ll get the exact time of sunrise.

Seen from the center of StonehengeStonehenge, the HeelstoneHeelstone (Stonehenge) is at azimuth (360/7)°. Does the rising Sun line up with the stone on the first day of summer? Check out the other henges: ManhattanhengeManhattanhenge, MITHengeMITHenge, etc.

Does your initial azimuth jitterjitter (of numeric value) between 0.00 and 360.00? It’s because a number in the range 359.995 to 360 inclusive is formatted by "%6.2f" as the string "360.00". Intercept any number in this range and change it to zero: local azimuth = math.deg(altaz.azimuth) if azimuth >= 359.995 then azimuth = 0 end --Now format the azimuth with "%6.2f".

Does your initial altitude jitter between 0.00 and -0.00? It’s because a negative number greater than –.005 is formatted by "%6.2f" as the string " -0.00". Intercept any number in this range and change it to zero: local altitude = math.deg(altaz.altitude) if altitude > -.005 and altitude < 0 then altitude = 0 end --Now format the altitude with "%6.2f".

The above program positions the observer well above the visible atmosphereatmosphere, scattering of light by, even though it takes the atmosphereHeight from the Earth’s info table (infoTable). In fact, Celestia renders an atmosphere only as high as the altitude where the scattering of light is only 5% of the surface value. See Object:setatmosphereObject:setatmosphere. and change the observer’s altitude to the following.

local mieScaleHeight = 12 --from Earth in data/solarsys.ssc local atmosphereExtinctionThreshold = .05 local kilometers = earth:radius() - mieScaleHeight * math.log(atmosphereExtinctionThreshold)

Print the observer’s azimuth as a cardinal direction as well as an angle. Create the following table of 16 strings at the top of the program, outside of any function. (It’s not an array because the keys do not begin with 1.)

--variables used by tickHandler: local direction = { [0] = "north", --first key is 0 "north by northeast", "northeast", "east by northeast", "east", "east by southeast", "southeast", "south by southeast", "south", "south by southwest", "southwest", "west by southwest", "west", "west by northwest", "northwest", "north by northwest" --last key is 15 } local n = #direction + 1 --the number of strings (16)

Insert the following code immediately before printing the string s in the tick handler. The altaz.azimuth is the range 0 to almost 2π. We can reduce it to the range 0 to almost 1 by dividing it by 2π. Then we blow it back up to the range 0 to almost n by multiplying it by n. Roundedstd.round to the closest whole number, it can be used as a key in the table.

--If not looking straight up or straight down, if math.abs(altaz.altitude) ~= math.pi then local d = std.round(altaz.azimuth * n / (2 * math.pi)) if d >= n then d = d - n end s = s .. "\nfacing " .. direction[d] end MM Altitude 0.00° Azimuth 0.00° facing north

The Celestial Spherecelestial sphere

If the Earth became transparent, or if we pressed control-w for the OpenGL wireframe modewireframe mode (OpenGL), we would see the entire celestial sphere that Olaf StapledonStapledon, Olaf envisioned. Our tiny Earth is located at the center of the sphere. From the Earth, we look outwards towards the inner surface of the sphere.

The 88 constellationsconstellations cover the surface of the sphere as the oceans and continents cover the surface of the Earth. Each constellation is a convenient name for a direction, or a bundle of directions, in which we can look from the Earth. The constellation of Ursa MinorUrsa Minor, for example, includes the direction from the center of the Earth towards the Earth’s North Pole. SagittariusSagittarius includes the direction from the Earth towards the center of our galaxy. The borderlandTaurus/Gemini of GeminiGemini and TaurusTaurus includes the direction from the Earth towards the Sun on the first day of each year’s summer in the Earth’s northern hemisphere.

Let’s look past the Earth towards the inner surface of the celestial sphere. We will pick a vantage point above the subsolar meridiansubsolar meridian, the line of longitude that goes through the subsolar point on the surface of the Earth. See lockFrame for the lock frame and erect for the Vector:erect. We will fast-forward through one rotation of the EarthEarth, rotation of with an abrupt start and end. See gradualStart to make the Earth pick up speed gradually.

--[[
Watch one rotation of the Earth at high speed.  The tickHandler holds the
observer above the point where the subsolar meridian crosses the equator.
]]
require("std")

--variables used by tickHandler:
local observer = celestia:sane()
--simulation time of end of fast-forward: 1 day after logo disappears
local t1 = celestia:gettime() + std.logo / (60 * 60 * 24) + 1

local sol = celestia:find("Sol")
local earth = celestia:find("Sol/Earth")
local bodyfixedFrame = celestia:newframe("bodyfixed", earth)
local lockFrame = celestia:newframe("lock", earth, sol)
local microlightyears = 5 * earth:radius() / KM_PER_MICROLY

local function tickHandler()
	--Go back to the normal time scale after one day of simulation time.
	if celestia:gettime() >= t1 then
		celestia:settimescale(1)
	end

	--toNorthPole is a unit vector in universal coordinates
	--from center of Earth towards North Pole of Earth.
	local toNorthPole = bodyfixedFrame:from(std.yaxis)

	--toSol is a unit vector in universal coordinates
	--from center of Earth towards center of Sun.
	local toSol = lockFrame:from(std.xaxis)

	--v is a unit vector in universal coordinates in plane of toNorthPole
	--and toSol, and perpendicular to toNorthPole.
	local v = toNorthPole:erect(toSol)

	local position = earth:getposition()
	observer:setposition(position + microlightyears * v)
	observer:lookat(position, toNorthPole)
end

local function main()
	--clouds distracting, ecliptic irrelevant
	celestia:hide("cloudmaps", "cloudshadows", "ecliptic")
	celestia:show("boundaries")	--of constellations
	celestia:hidelabel("planets")	--We know the Earth is the Earth.

	--Pass a table to a method.  Don't need parentheses around the table.
	earth:addreferencemark({type = "body axes"})
	earth:addreferencemark({type = "planetographic grid"})
	--earth:addreferencemark({type = "sun direction"})

	observer:setframe(lockFrame)
	celestia:registereventhandler("tick", tickHandler)

	--Wait till the "CELESTIA" logo disappears before starting fast-forward.
	wait(std.logo)

	--one hour of simulation time per second of real time
	celestia:settimescale(60 * 60)
	wait()
end

main()

Equivalently, we can think of the Earth as remaining motionless at the center of the celestial sphere. The sphere would then rotate around the Earth once per day, as the world spins around a person standing on a Lazy Susan. Let’s fast-forward through one rotation, hovering in geostationary orbitgeostationary orbit over the point at latitude 0° longitude 0° off the west coast of Africa.

--[[
Watch one rotation of the Earth while hovering over the South Atlantic.
]]
require("std")

--variable used by tickHandler:
local t1 = celestia:gettime() + std.logo / (60 * 60 * 24) + 1

local function tickHandler()
	--Go back to the normal time scale after one day of simulation time.
	if celestia:gettime() >= t1 then
		celestia:registereventhandler("tick", nil)
		celestia:settimescale(1)
	end
end

local function main()
	local observer = celestia:sane()
	--clouds distracting, ecliptic irrelevant
	celestia:hide("cloudmaps", "cloudshadows", "ecliptic")
	celestia:hidelabel("planets")	--We know the Earth is the Earth.

	local earth = celestia:find("Sol/Earth")
	earth:addreferencemark({type = "spin vector"})	 --Earth's axis
	earth:addreferencemark({type = "sun direction"}) --Dress up subsolar pt.

	--Show the terminator (border of day and night) as a yellow line.
	earth:addreferencemark({
		type = "visible region",
		target = celestia:find("Sol")
	})

	local bodyfixedFrame = celestia:newframe("bodyfixed", earth)
	observer:setframe(bodyfixedFrame)

	local position = celestia:newpositionlonglat(
		math.rad(0),	--longitude: on the prime meridian
		math.rad(0),	--latitude: on the equator
		6 * earth:radius() / KM_PER_MICROLY)

	observer:setposition(bodyfixedFrame:from(position))

	--Face the center of the Earth, with Earth's North Pole at the top.
	observer:lookat(earth:getposition(), bodyfixedFrame:from(std.yaxis))

	wait(std.logo)		--until "CELESTIA" logo disappears
	celestia:registereventhandler("tick", tickHandler)

	--one hour of simulation time per second of real time
	celestia:settimescale(60 * 60)
	wait()
end

main()

Is PolarisPolaris (star) really the North Star? Let the observer hover over Antarctica by changing his latitude to math.rad(-90). To reduce the area of the sky blocked by the Earth, increase the distance to 12 or more times the Earth’s radius. You could also toggle the OpenGL wireframe mode with control-w to let Polaris shine through the Earth.

Set the above program to 2788 B.C., when the pole star was ThubanThuban (star) (α Draconis). Since there was no year 0 A.D., the argument –2787 takes us to 2788 B.C.

--North Pole closest to Thuban on August 24 2788 B.C., 1:00 a.m. UTC celestia:settime(celestia:utctotdb(-2787, 8, 24, 1)) celestia:select(celestia:find("Thuban"))

The North Star of the Earth is Polaris. Verify that the South Star of SaturnSaturn, South Star of is δ Octantisδ Octantis (star).

Right Ascensionright ascension and Declinationdeclination: the J2000 Equatorial CoördinatesJ2000 equatorial coördinates

We just saw the celestial sphere rotating around a stationary Earth. Like any rotating sphere, it has a north polenorth celestial pole, south pole, and equatorcelestial equator. An observer standing at the North Pole of the Earth would see the north pole of the celestial sphere directly overhead. An observer on the Earth’s equator would see a point on the celestial sphere’s equator overhead.

Given the north pole and equator of the celestial sphere, we can mark out another system of latitude and longitude on it. The latitude is called declination and is measured north and south from the celestial equator. The longitude is called right ascension and is measured counterclockwise from a meridian on the celestial sphere that goes through a point in the constellation PiscesPisces. This point is the vernal equinoxvernal equinox, the direction from the Earth to the Sun on the first day of spring in the Earth’s northern hemisphere. In the same way, east longitude on Earth is measured counterclockwise from a meridian on the terrestrial sphere that goes through a point in Greenwich, EnglandGreenwich, England. Right ascension goes from 0 to almost 360°. Celx expresses this range as 0 to almost 2π radians (radians). Human beings express it as 0 to almost 24 hours (hours), abbreviated with a superscript “h”.

For example, Pisces itself is at Right Ascension 0h and Declination 0°. (It’s like the famous point off the west coast of Africa at longitude East and latitude North). The constellation group Taurus/GeminiTaurus/Gemini, 90° counterclockwise from Pisces, is at Right Ascension 6h and Declination 23° North. (It’s like Bangladesh at longitude 90° East and latitude 23° North). The North StarNorth Star in Ursa MinorUrsa Minor is at an almost irrelevant right ascension and a declination of almost 90° North. (It’s like the Earth’s North Pole at an irrelevant longitude and latitude 90° North.)

The right ascension and declination coördinates are known as the J2000 equatorial coördinates. Do not confuse them with the “equatorial frames” in equatorialFrame. One complication: the Earth’s axis points in different directionsprecession of Earth’s axis at different times, moving the north celestial pole along the surface of the celestial sphere. The J2000 means that the equatorial coördinates were defined using the Earth’s axis as it was on January 1, 2000 at noon UTC. (The J stands for Julian, not January.)

The geographical convention is to cite the latitude followed by the longitude (despite the Celx method Observer:gotolonglat). The astronomical convention is to cite the right ascension followed by the declination. Thus BetelgeuseBetelgeuse (star) is at RA 5h 55m Dec +7° 24′.

The following program displays the right ascension and declination of the direction in which the observer is facing. The right ascension is displayed in hours, minutes and seconds (hours); the declination in degrees, minutes, and seconds (degrees). For the frames of reference, see rotatedRadec.

--[[
Display the right ascension and declination of the direction in which the
observer is facing.  To change his direction, press the arrows or drag on the
sky.
]]
require("std")

--variables used by tickHandler:
local observer = celestia:sane()
local radecFrame = celestia:newframe("universal", std.radecRotation)

local function tickHandler()
	--Get the observer's forward vector in universal frame coordinates:
	--X axis points towards Pisces, Y axis towards Draco.
	local forward = observer:getorientation():transform(std.forward)

	--Rewrite the vector in radecFrame coordinates:
	--X axis points towards Pisces, Y axis towards Polaris.
	forward = radecFrame:to(forward)

	--Convert Cartesian coordinates to spherical.
	local longlat = forward:getlonglat()

	--Break down radians into conventional units.
	local ra = std.tora(longlat.longitude)
	local dec = std.todec(longlat.latitude)

	local s = string.format(
		"RA %dh %dm %.2fs\n"
		.. "Dec %s%d%s %d%s %.2f%s",
		ra.hours, ra.minutes, ra.seconds,
		std.sign(dec.signum),
		dec.degrees, std.char.degree,
		dec.minutes, std.char.minute,
		dec.seconds, std.char.second)

	celestia:mmprint(s)
end

local function main()
	--Keep the Sun in view, but get away from its glare.
	observer:setposition(std.position)

	--Look towards RA 6h Dec +23 degrees in Gemini/Taurus,
	--with north pole of the ecliptic in Draco directly overhead.
	local forward =
		celestia:newvectorradec(math.rad(360 * 6 / 24), std.tilt)
	local up = celestia:newvectorradec(math.rad(360 * 18 / 24),
		math.rad(90) - std.tilt)
	local orientation = celestia:newrotationforwardup(forward, up)
	observer:setorientation(orientation)

	celestia:registereventhandler("tick", tickHandler)
	wait()
end

main()
MM RA 6h 0m 0.00s Dec +23° 26′ 21.45″

The unit vector pointing forward to Taurus/Gemini may be written in any of the following ways. Which is simplest?

celestia:newvectorradec(math.rad(360 * 6 / 24), std.tilt) celestia:newvector(0, 0, -1) -std.zaxis std.forward

The unit vector pointing straight up to DracoDraco may be written in any of the following ways. Which is simplest?

celestia:newvectorradec(math.rad(360 * 18 / 24), math.rad(90) - std.tilt) celestia:newvector(0, 1, 0) std.yaxis std.up

The initial right ascension in the above program is 6h 0m 0.00s. When you press the down arrow and pitch upwards, do you ever get an annoying right ascension of 5h 59m 60.00s? If so, intercept the 60.00s before it is printed. See the jitter examples in celestialDome. if ra.seconds > 59.995 then ra.seconds = 0 ra.minutes = ra.minutes + 1 if ra.minutes == 60 then ra.minutes = 0 ra.hours = ra.hours + 1 if ra.hours == 24 then ra.hours = 0 end end end And similarly for declination: if dec.seconds > 59.995 then dec.seconds = 0 dec.minutes = dec.minutes + 1 if dec.minutes == 60 then dec.minutes = 0 dec.degrees = dec.degrees + 1 assert(dec.degrees <= 90) end end

The J2000 equatorial coördinate system can be provided with the following Cartesian axes. The XZ plane is the plane of the celestial equator. The X axis points towards the vernal equinox at RA 0h in Pisces. This is the direction from the Earth towards the Sun on the first day of spring in the Earth’s northern hemisphere. The Z axis points towards RA 18h in the constellation OphiuchusOphiuchus. The Y axis points towards the north celestial pole near Polaris.

The following diagram looks down from Polaris onto the XZ plane. The Y axis points straight up towards us and is invisible.

The Eclipticecliptic

It takes a full year for the Earth to revolveEarth, orbit of around the Sun. Let’s fast-forward through the current hour of the current year.

The following program positions the observer slightly beyond the orbit of the Earth, shown as a horizontal red line. The Earth will travel from left to right along the orbit as we look inwards towards the Sun. Satisfy yourself that the Earth circles the Sun counterclockwise when viewed from above the Earth’s north pole, looking down at the Earth. We can uncomment the planetographic grid to spruce up the dark side of the Earth.

--[[
Watch the Earth revolve around the Sun for one hour of simulation time.
Keep the Earth's orbit horizontal.
]]
require("std")

--variables used by tickHandler:
local minutes = 60               --fast forward through this number of minutes
local days = minutes / (24 * 60) --fast forward through this number of days

--times of start and end of fast-forward
local t0 = celestia:gettime() + std.logo / (60 * 60 * 24) - days / 2
local t1 = t0 + days

local function tickHandler()
	if celestia:gettime() >= t1 then
		celestia:registereventhandler("tick", nil)
		celestia:settimescale(1)
	end
end

local function main()
	local observer = celestia:sane()
	--When the Earth is selected, make its orbit visible.
	--Show no other lines.
	celestia:hide("grid", "ecliptic")
	celestia:show("orbits")
	celestia:hidelabel("planets")	--We know that the Earth is the Earth.

	--No other orbit should be visible.
	celestia:setorbitflagsCelestia:setorbitflags({Planet = false, Moon = false})

	local sol = celestia:find("Sol")
	local earth = celestia:find("Sol/Earth")
	celestia:select(earth)	--Make Earth's orbit red.
	--earth:addreferencemark({type = "planetographic grid"})

	--Observer looks sunwards from a point 12 radii beyond Earth's orbit.
	local lockFrame = celestia:newframe("lock", earth, sol)
	local microlightyears = 12 * earth:radius() / KM_PER_MICROLY
	local position = celestia:newposition(-microlightyears, 0, 0)
	observer:setposition(lockFrame:from(position))

	--Orient the observer so that the axis of the Earth's orbit points up.
	observer:lookat(sol:getposition(), std.yaxis)

	celestia:settime(t0)
	wait(std.logo)		--until "CELESTIA" logo disappears
	celestia:registereventhandler("tick", tickHandler)

	--3 minutes of simulation time per second of real time
	celestia:settimescale(60 * 3)
	wait()
end

main()

Don’t hardwire the number of microlightyears into the above program. Let it program compute how far out from the Earth’s orbit the observer would have to be to make the Earth go across the window during one hour of simulation time. local daysInYear = earth:getinfo().orbitPeriod local minutesInYear = daysInYear * 24 * 60 --circumference in astronomical unitsastronomical unit of Earth's orbit local circumference = 2 * math.pi --length in astronomical units of arc traveled in minutes/2 minutes. local arc = circumference * (minutes / 2) / minutesInYear --Assume the arc is practically a straight line. local tangent = math.tanmath.tan(observer:gethorizontalfov() / 2) assert(tangent > 0) local farOut = arc / tangent --in astronomical units --Convert astronomical units to microlightyears. local microlightyears = farOut * std.microlyPerAu

Could we get greater accuracy by using the Earth’s current distance from the Sun? Or by acknowledging that the arc is curved?

Consider a point on the Earth’s equator. What makes a bigger contribution to its speed with respect to the Sun: the Earth’s rotation around its axis, or the Earth’s revolution around the Sun?

The previous program showed the Earth revolving around a stationary Sun. In another frame of reference, the Sun would revolve around a stationary Earth. The Earth would still rotate around its axis, but the center of the Earth would remain motionless.

From the point of view of this Earth, the Sun would make an annual counterclockwise circle, called the ecliptic, along the surface of the celestial sphere. The ecliptic is a great circlegreat circle, the same size as the celestial equator but tilted about 23° therefrom. (The exact angle in radians is std.tiltstd.tilt.) The north pole of the eclipticnorth pole of ecliptic is in the constellation DracoDraco, 23° from the north celestial polenorth celestial pole near PolarisPolaris (star). The south pole of the eclipticsouth pole of ecliptic is in the constellation DoradoDorado, 23° from the south celestial polesouth celestial pole in OctansOctans.

Let’s watch the Sun travel along the ecliptic as we fast-forward through 48 days. The ecliptic appears as a straight line because the celestial sphere is projected onto the window with a gnomonic projectiongnomonic projection, in which all great circles appear as straight lines. The constellations through which the ecliptic passes are called the zodiaczodiac; their names are listed in std.zodiacstd.zodiac. We’ll give them the same red color as the ecliptic, the Renderer::EclipticColorRenderer::EclipticColor (C++ constant) in version/src/celengine/render.cpp.

--[[
Watch the Sun travel along the ecliptic for 48 days of simulation time.
Keep Polaris overhead.
]]
require("std")

--variables used by tickHandler:
local days = 48	--fast forward through this number of days

--times of start and end of fast-forward
local t0 = celestia:gettime() + std.logo / (60 * 60 * 24) - days / 2
local t1 = t0 + days

local function tickHandler()
	if celestia:gettime() >= t1 then
		celestia:registereventhandler("tick", nil)
		celestia:settimescale(1)
	end
end

local function main()
	local observer = celestia:sane()
	celestia:hide("grid")		--right ascension and declination
	celestia:show("eclipticgrid")	--ecliptic latitude and longitude
	celestia:hidelabel("planets")	--We know that the Earth is the Earth.

	--Make the zodiac the same color as the ecliptic.
	celestia:setconstellationcolor(.5, .1, .1, std.zodiac)

	local sol = celestia:find("Sol")
	local earth = celestia:find("Sol/Earth")
	local eclipticFrame = celestia:newframe("ecliptic", earth)
	observer:setframe(eclipticFrame)

	local lockFrame = celestia:newframe("lock", earth, sol)
	local microlightyears = 12 * earth:radius() / KM_PER_MICROLY
	local position = celestia:newposition(-microlightyears, 0, 0)
	observer:setposition(lockFrame:from(position))

	--Orient the observer so that the Earth's axis points up.
	local equatorialFrame = celestia:newframe("equatorial", earth)
	observer:lookat(sol:getposition(), equatorialFrame:from(std.yaxis))

	celestia:settime(t0)
	wait(std.logo)	--until "CELESTIA" logo disappears
	celestia:registereventhandler("tick", tickHandler)

	--one day of simulation time per 2 seconds of real time
	celestia:settimescale(60 * 60 * 24 / 2)
	wait()
end

main()

The above program shows the Sun moving along the ecliptic on the current date. Watch it on the first day of spring in the northern hemisphere of Earth. Insert the following statements immediately before creating t0.

local year = celestia:tdbtoutc(celestia:gettime()).year --current year celestia:settime(celestia:utctotdb(year, 3, 21))

In the spring, the Sun moves along the part of the ecliptic that crosses the celestial equator in Pisces, going uphill at an angle of 23°. To see the angle, do not hide the "grid" and do not show the "eclipticgrid". The exact point at which the ecliptic crosses the equator is hidden by the Earth in this program. It is called the vernal equinoxvernal equinox and is located in the constellation PiscesPisces. This crossing marks the start of spring.

Watch the Sun on the first day of summer, fall, and winter, too. See the diagram of the plane of the ecliptic in universalFrame, also known as “the XZ plane of the universal frame”.

The above exercise showed that the Sun crosses the celestial equator twice a year.

  1. The uphill crossing point in Pisces (the vernal equinox) marks the Prime Meridian of the celestial sphere at Right Ascension 0h. Pisces is the direction in which the Sun lies, as seen from the Earth, on the first day of springspring in the northern hemisphere of Earth.
  2. On the other side of the celestial sphere from the vernal equinox is the autumnal equinoxautumnal equinox, the downhill crossing point in VirgoVirgo at RA 12h. This is the direction in which the Sun lies, as seen from the Earth, on the first day of autumnautumn in the northern hemisphere of Earth.
  3. Halfway between the equinoxes, a point near the boundary of Taurus and GeminiTaurus/Gemini is the summer solsticesummer solstice, the northernmost point on the ecliptic at RA 6h. This is the direction in which the Sun lies, as seen from the Earth, on the first day of summersummer in the northern hemisphere of Earth.
  4. On the other side of the sphere from the summer solstice is the winter solsticewinter solstice in SagittariusSagittarius, the southernmost point on the ecliptic at RA 18h. This is the direction in which the Sun lies, as seen from the Earth, on the first day of winterwinter in the northern hemisphere of Earth.

The Colurescolure

The great circle formed by the meridians of 0h and 12h on the celestial sphere is called the equinoctial colureequinoctial colure. Perpendicular to it, the great circle formed by the meridians of 6h and 18h is called the solstitial coluresolstitial colure. The colures are perpendicular to the celestial equator and intersect at the north celestial polenorth celestial pole near PolarisPolaris (star).

The solstitial colure will be of greater importance to us because by defaultorientation, default the observer faces towards a point on it. This colure goes through the following four points. They will be our landmarkslandmarks (on celestial sphere). Memorize them.

|
|
DracoDraco (north pole of eclipticecliptic), at RA 18h Dec +67°
By default, this is the observer’s zenithzenith.

 23°

Polaris (north pole of celestial equatorcelestial equator), at RA <irrelevant> Dec +90°

|
|
 67°
|
|

Taurus/GeminiTaurus/Gemini (the summer solsticesummer solstice), on the ecliptic at RA 6h Dec +23°
By default, the observer faces towards this point.

 23°

OrionOrion, on the celestial equator at RA 6h Dec 0°
|
|

The following program displays the part of the solstitial colure that contains the four points. The colure is the vertical line down the middle of the window. When the observer faces a landmark, we will highlight it by doubling the Renderer::ConstellationColorRenderer::ConstellationColor (C++ constant) in version/src/celengine/render.cpp.

--[[
Visit and highlight our four landmarks along the solstitial colure.
Press the up and down arrows or drag on the sky.
]]
require("std")

--variables used by tickHandler:
local observer = celestia:sane()
local radecFrame = celestia:newframe("universal", std.radecRotation)
local constellationError = math.rad(5)
local printError = math.rad(2)

local landmarks = {
	{0,                      {"Orion"},            "celestial equator"},
	{std.tilt,               {"Gemini", "Taurus"}, "summer solstice"},
	{math.pi / 2,            {"Ursa Minor"},       "north celestial pole"},
	{math.pi / 2 + std.tilt, {"Draco"},            "north pole of ecliptic"}
}

local function tickHandler()
	--Unit vector in universal coordinates
	--along which the observer is looking.
	local forward = observer:getorientation():transform(std.forward)

	--[[
	Let the observer pitch up and down the solstitial colure, but don't let
	him yaw left and right.  If he tries to yaw, make his forward vector
	point at the closest point on the colure.  If there is no closest point,
	point towards Taurus/Gemini.
	]]
	if (forward ^ std.xaxis):length() == 0 then
		forward = -std.zaxis	--Taurus/Gemini
	else
		forward = std.xaxis:erect(forward)
	end

	--Point the observer along the new forward vector,
	--with the X axis of the universal frame pointing to his right.
	observer:lookat(observer:getposition() + forward, std.xaxis ^ forward)

	--Get the RA, Dec coordinates of the observer's forward vector.
	local longlat = radecFrame:to(forward):getlonglat()

	--Break down radians into conventional units.
	local ra = std.tora(longlat.longitude)
	local dec = std.todec(longlat.latitude)

	local s = string.format(
		"RA %dh %dm %.2fs\n"
		.. "Dec %s%d%s %d%s %.2f%s",
		ra.hours, ra.minutes, ra.seconds,

		std.sign(dec.signum),
		dec.degrees, std.char.degree,
		dec.minutes, std.char.minute,
		dec.seconds, std.char.second)

	--how far north of Orion
	local theta = math.atan2(forward:gety(), -forward:getz()) + std.tilt

	for i, landmark in ipairs(landmarks) do
		if math.abs(theta - landmark[1]) < constellationError then
			--Highlight the set of constellations.
			celestia:setconstellationcolor(2 * 0, 2 * .24, 2 * .36,
				landmark[2])
		else
			--Go back to original color.
			celestia:setconstellationcolor(0, .24, .36, landmark[2])
		end

		if math.abs(theta - landmark[1]) < printError then
			s = s .. "\n" .. landmark[3]
		end
	end

	celestia:print(s, 60, 0, 0, 0, 0)	--center of window
end

local function main()
	--Put the Sun behind us,
	--so it doesn't block our view of the summer solstice.
	observer:setposition(celestia:newposition(0, 0, -1))

	celestia:print("Press the up and down arrows.", std.logo, 0, 0, 0, 0)
	wait(std.logo)
	celestia:registereventhandler("tick", tickHandler)
end

main()
RA 6h 0m 0.00s Dec 23° 26′ 21.45″ summer solstice

We now have three systems of coördinates we can impose on the celestial sphere.

  1. Our equator can be the horizon of an observer standing on the Earth—or other celestial object—at a given place and time. The point on this equator at longitude is the north point on the horizon. The longitude (called azimuth) increases to the right (clockwise). These conventions give rise to altazimuth coördinates (celestialDome).
  2. Our equator can be the celestial equator. The point on this equator at longitude is the vernal equinox in Pisces. The longitude (called right ascension) increases to the left (counterclockwise). These conventions give rise to J2000 equatorial coördinates (j2000Equatorial).
  3. Our equator can be the ecliptic. The point on this equator at longitude is the vernal equinox in Pisces. The longitude (called ecliptic longitude) increases to the left (counterclockwise). These conventions give rise to ecliptic coördinates.

The planes of the equators of the equatorial and ecliptic systems are separated by an angle of 23° (std.tilt). The same angle separates the north pole of the celestial equator near Polaris from the north pole of the ecliptic in Draco.

Later we will have additional systems of coördinates, the galactic and supergalactic in galacticCoordinates and supergalacticCoordinates. And we could easily invent more of them. Imagine a system whose north pole is the radiant point of the Perseid meteor showerPerseid meteor shower, or the solar apexsolar apex, or the warmest spot in the cosmic microwave background radiationcosmic microwave background radiation. We will draw their grids in traceRetrograde.

The Universal Frame

The distinction we made in time between simulation time and real time corresponds to the one we now make between simulation space and real space. Simulation spacesimulation space is home to the observer, who roams across the lightyears of the Celestia universe. Real spacereal space is home to his alter ego, the user, who remains fixed at a point 400 millimeters (about 16 inches) in front of the Celestia window. This default is set by the distanceToScreendistanceToScreen (C++ variable) in version/src/celestia/celestiacore.cpp and the REF_DISTANCE_TO_SCREENREF_DISTANCE_TO_SCREEN (C++ variable) in version/src/celengine/render.cpp. It can be changed with Observer:setfovObserver:setfov, since a wider field of view pulls the user closer to the window. The distance can also be changed in a Celestia compiled --with-kdeKDE (K Desktop Environment) by selecting Settings → Configure Celestia… → Viewing Distance.

A third kind of space is the domain of the OpenGL graphics in opengl. It remains two-dimensional unless we call gl.Frustumgl.Frustum and glu.LookAtglu.LookAt. A fourth kind of space is the underworld where Celestia goes when it divides by zerodivision by zero. But the primary space of Celestia is simulation space, to which the rest of §11 is devoted.

Frames of Reference

When [David] HoagHoag, David looked at the problem, he found that all of the thrusting that the command moduleApollo Command Module would be doing, and hence all of the accelerations that the inertial system would have to measure, were limited to a single plane.… Hoag realized the system simply did not have to fly about in all directions—it only had to fly to the moon and back. Put another way, the spacecraft needed to fly around the moon but not over it.
David A. MindellMindell, David A., Digital Apollo: Human and Machine in SpaceflightDigital Apollo: Human and Machine in Spaceflight (Mindell) (2008)

The position of an observer or a celestial object is measured with respect to a Celx frame of referenceframe of reference. A frame has three perpendicular axesaxis (of frame of reference) meeting at an originorigin (of frame of reference). The unit of distance along all three axes is the microlightyear in lightyear. The same unit is used by all frames: the Lorentz-FitzGerald contractionLorentz-FitzGerald contraction does not occur in Celestia. The scale remains constant: a frame does not supply the comoving coördinatescomoving coördinates with which cosmologists describe the expansion of the UniverseUniverse, expansion of. And all frames share the same time: EinsteinEinstein, Albert’s twin paradoxtwin paradox does not arise in Celestia. Note, however, that it is possible for different observers to inhabit different points in time; see Celestia:synchronizetimeCelestia:synchronizetime.

A Celx frame is right-handedright-handed frame, a convention inherited from the OpenGLOpenGL and right-handedness graphics library. This means that when the X axis points to our right and the Y axis points up, the Z axis will point towards us. To illustrate right-handedness, the following diagram looks down on the XY plane of a frame of reference. Since the frame is right-handed, the Z axis is determined by the other two axes. It points straight up towards us and is invisible in the diagram.

The fundamental planefundamental plane (of frame) of a frame of reference is the plane where most of the action is. The XY plane is the fundamental plane for two sets of frames: the chase frames in chaseFrame and the lock frames in lockFrame. But all of the other frames prefer the XZ plane, shown in the following diagram. Since the frame is right-handed, the Y axis points straight up towards us and is invisible.

CartesianCartesian coördinates vs. Polar Coördinatespolar coördinates

Given a point with Cartesian coördinates (x, y, z), we may need to find its spherical coördinates. But first we have to decide on names for them. Since there are so many conventions (latitude vs. colatitude, right ascension vs. azimuth, θ vs. φ), we will fall back on the standard names latitude, longitude, and distance. The latitude will be an angle in radians measured up or down from the XZ plane. The longitude will be an angle in radians in the XZ plane, measured counterclockwise from the positive X axis. The distance will be in microlightyears from the origin.

The standard library methods Position:getlonglatPosition:getlonglat and Vector:getlonglatVector:getlonglat convert the Cartesian coördinates of a position or vector to spherical. The methods Celestia:newpositionlonglatCelestia:newpositionlonglat and Celestia:newvectorlonglatCelestia:newvectorlonglat convert spherical coördinates back to Cartesian and deposit them in a new object.

--[[ Convert Cartesian coordinates to spherical. x, y, z are in microlightyears. longlat.latitude and longlat.longitude are in radians. longlat.distance is in microlightyears and is nonnegative. ]] local position = celestia:newposition(x, y, z) local longlat = position:getlonglat() --longlat is a table of 3 fields. local s = string.format( "latitude = %.15g%s, longitude = %.15g%s, distance = %.15g", math.deg(longlat.latitude), std.char.degree, math.deg(longlat.longitude), std.char.degree, longlat.distance) --[[ Convert spherical coordinates to Cartesian. latitude and longitude are in radians. distance, in microlightyears, must be nonnegative and defaults to 1. x, y, z are in microlightyears. ]] local position = celestia:newpositionlonglat(longitude, latitude, distance) local s = string.format( "x = %.15g, y = %.15g, z = %.15g", position:getx(), position:gety(), position:getz())

An angle in radians that represents a right ascension or declination may be broken down into conventional units by std.torastd.tora and std.todecstd.todec. The resulting numeric values are nonnegative, with one exception. The field dec.signum is one of the numbers 1, 0, or –1 to indicate north, equator, or south. The function std.signstd.sign returns one of the strings "+", "", or "-".

--longitude and latitude are angles in radians; ra and dec are tables. local ra = std.ra(longitude) local dec = std.dec(latitude) local s = string.format( "RA %dh %dm %.15gs\n" .. "Dec %s%d%s %d%s %.15g%s", ra.hours, ra.minutes, ra.seconds, std.sign(dec.signum), --"+", "-", or "" dec.degrees, std.char.degree, dec.minutes, std.char.minute, dec.seconds, std.char.second) RA 1h 2m 3s Dec +4° 5′ 6″

The Origin and Axes of the Universal Frameuniversal frame

Spatium absolutumabsolute space natura sura absq; relatione ad externum quodvis semper manet similare & immobile;

Absolute Space, in its own nature, without regard to any thing external, remains always similar and immovable.

Isaac NewtonNewton, Isaac, Philosophiæ Naturalis Principia MathematicaPrincipia Mathematica (Newton) (1687)

The simplest Celx frame is the universal frame. Its origin is the barycenterbarycenter (of Solar System) (center of mass) of the Solar SystemSolar System, universal frame and, always in or near the Sun. In the Celestia universe, the universal frame is “at rest”. The Solar System does not orbit the Galactic Center, the Milky WayMilky Way, approach towards Andromeda of is not approaching the Andromeda GalaxyAndromeda Galaxy (M31), and the Local GroupLocal Group (of galaxies) does not recede from the Virgo ClusterVirgo Cluster (of galaxies). If Celestia had the cosmic microwave background radiationcosmic microwave background radiation, it would be isotropic with respect to the universal frame.

Ancient versions of Celestia had a different origin, one milliparsec from the Sun—about 206 astronomical units—in the direction of the autumnal equinoxautumnal equinox in VirgoVirgo. This distance still shows up when you press the Follow Earth button in a Celestia compiled --with-kdeKDE (K Desktop Environment).

The XZ plane of the universal frame is the plane of the eclipticecliptic (theecliptic). This is the plane of the Earth’s orbit around the Sun and the plane of the Sun’s apparent orbit around the Earth. The axes are defined in terms of the locations of the equinoxes and solstices on January 1, 2000 at noon TTTT (Terrestrial Time) (Terrestrial TimeTerrestrial Time, stepThrough).

  1. The X axis points towards the vernal equinoxvernal equinox at RA 0h Dec 0° in the constellation PiscesPisces. This is the direction from the Earth towards the Sun on the first day of spring in the Earth’s northern hemisphere. The X axis points away from the autumnal equinox at RA 12h Dec 0° in Virgo. This is the direction from the Earth towards the Sun on the first day of autumn.
  2. The Z axis points towards the winter solsticewinter solstice at RA 18h Dec –23.4392911° in SagittariusSagittarius. This is the direction from the Earth towards the Sun on the first day of winter in the Earth’s northern hemisphere. We will abbreviate this angle as 23° and its complementcomplementary angles as 67°. The Z axis points away from the summer solsticesummer solstice at RA 6h Dec +23° in the borderlands of Taurus and GeminiTaurus/Gemini. This is the direction from the Earth towards the Sun on the first day of summer, and the direction of the observer’s view in his initial orientationorientation, initial.
  3. The Y axis is determined by the other two axes because of the right-hand ruleright-handed frame in framesOfReference. It points points towards the north pole of the eclipticnorth pole of ecliptic at at RA 18h Dec +67° in DracoDraco. This is the direction towards the observer’s zenithzenith in his initial orientation. The Y axis points away from the south pole of the eclipticsouth pole of ecliptic at RA 6h Dec +67° in DoradoDorado. This is the direction towards the observer’s nadirnadir in his initial orientation.

Looking down at the XZ plane from Draco, the positive Y axis points straight towards us and is invisible. The Sun remains near the origin. On approximately March 21 of each year, the Earth is to the left of the origin and the line from the Earth towards the Sun points towards Pisces. On April 21 the Earth is to the lower left of the origin, and the line from the Earth towards the Sun points towards AriesAries.

The constellations in the diagram have been spaced at equal intervals like the numbers on a clock. In the real sky they are somewhat irregular. For example, the summer solstice is in between Taurus and Gemini.

PositionsPosition (class)

A position object holds the Cartesian x, y, z coördinates of a position in the simulated universe. Each coördinate is stored in 128-bit fixed point format (positionCoordinates) and the unit of distance is the microlightyear (linearDistance). For the time being, the position is measured with respect to the universal frame. This will change in otherFrames when we get to the other frames.

Set and Get the Position of an Observer

The initial position of the observer is at the origin of the universal frame, in or near the Sun. That’s a dangerous place to be. To get away from the glare, the following program repositions him one microlightyear away from the origin, at a point on the positive Z axis of the universal frame.

In his initial orientationorientation, initial, the observer looks along the negative Z axis out towards z = –∞, with the north pole of the ecliptic in DracoDraco overhead at his zenithzenith. As long as he stays in this orientation, the Sun will remain reassuringly in view from his new position on the positive Z axis. The convention of looking along the negative Z axis, with the positive Y axis pointing up, is inherited from OpenGLOpenGL and initial orientation. When we discuss “vectors” in vectors, we will say that his orientation’s forward vectorforward vector of orientation is the negative Z axis, and its up vectorup vector of orientation is the positive Y axis. Unit vectorsunit vector (of length 1) in these directions are available as std.forwardstd.forward and std.upstd.up.

Beyond the Sun, we see the summer solsticesummer solstice at RA 6h Dec +23° in Taurus/GeminiTaurus/Gemini. Below them, OrionOrion completes Celestia’s signature triosignature trio of constellations. The X axis points towards PiscesPisces, out of the window to the observer’s right. The Y axis points up towards Draco, out of the window at the observer’s zenith. The Z axis points through the observer towards SagittariusSagittarius, out of the window to the observer’s rear.

The coördinates of the position passed to Observer:setpositionObserver:setposition are always measured with respect to the universal frame, even if the observer has been “adhered” to a different frame with Observer:setframeObserver:setframe (eclipticFrame). The same is true for a position returned by Observer:getpositionObserver:getposition.

--[[
Position the observer a safe distance from the Sun.
He will still be well within the orbit of Mercury.
]]
require("std")

local function main()
	local observer = celestia:sane()

	--One microlightyear from Solar System Barycenter
	--towards the winter solstice in Sagittarius:
	local position = celestia:newposition(0, 0, 1)

	assert(type(position) == "userdata" and
		tostring(position) == "[Position]")

	observer:setposition(position)
	wait()
end

main()

Our standard position (0, 0, 1) is in the library as std.positionstd.position. Use it in the above program place of the local position. Locate std.position in the above diagram. At that position, does the Sun block our view of Taurus/Gemini?

Verify that Observer:setposition worked correctly with the following call to Observer:getposition.

position = observer:getposition() assert(type(position) == "userdata" and tostring(position) == "[Position]") local s = string.format( "position = (%.15g, %.15g, %.15g)", position:getx(), position:gety(), position:getz()) position = (0, 0, 1)

An easier way to print a position is with Position:getxyzPosition:getxyz. Since it returns more than one valuereturn values, multiple, it can only be the last argument of string.formatstring.format.

local s = string.format( "position = (%.15g, %.15g, %.15g)", position:getxyz()) --returns three numbers

An individual coördinate of a position can be accessed in three ways. We will demonstrate with the x coördinate; the others are the same.

--Read-only access. local x = position:getx() --Read/write access. x = position.x position.x = 10 --Read/write access. x = position["x"] position["x"] = 10

The square brackets seem redundant until we see their real purpose: to let us loop through the coördinates with an iterator.

local s = "" for c in std.xyzstd.xyz() do assert(type(c) == "string") s = s .. string.format("position.%s = %.15g\n", c, position[c]) end position.x = 0 position.y = 0 position.z = 1

The observer adheresadherence of observer to frame to a frame. This means that he remains at the same position and orientation with respect to the frame, until disturbed by the user. Let’s verify that the frame to which he initially adheres is the universal frame.

local frame = observer:getframe() assert(type(frame) == "userdata" and tostring(frame) == "[Frame]") local name = frame:getcoordinatesystem() assert(type(name) == "string") local s = "The observer's frame is the " .. name .. " frame." The observer's frame is the universal frame.

Verify that the observer remains at the same position and orientation with respect to his frame. Fast forward interactively with the keys lowercase L and K, or with the following statement. --Fast-forward one year of simulation time per second of real time. celestia:settimescale(60 * 60 * 24 * 365.25) Note that the Solar System Barycenter remains anchored in front of the observer, blocking his view of the summer solstice in Taurus/Gemini. Then speed through time fast enough to make the Sun swing around the barycenter like a star with extrasolar planetsextrasolar planet.

Although a 128-bit position coördinate (positionCoordinates) has a higher level of precision than a conventional double (doubleArithmetic), it has a narrower range of values. For example, a double whose absolute value is greater than 253 − 1 253 · 263 = 263 – 210 = 9,223,372,036,854,774,784 results in garbage when copied into a coördinate of a position object. Let’s try that value and the next double value, which is 252 253 · 264 = 263 = 9,223,372,036,854,775,808

local good = celestia:newposition(2^63 - 2^10, 0, 0) local bad = celestia:newposition(2^63, 0, 0) local s = string.format( "good = (%.19g, %.19g, %.19g)\n" .. "bad = (%.19g, %.19g, %.19g)", good:getx(), good:gety(), good:getz(), bad:getxyz())

The field bad.x turns into garbage. There are no error messages or warnings.

good = (9223372036854774784, 0, 0) bad = (0, 0, 0)

Similarly, a double whose absolute value is less than 252 253 · 2–63 = 2–64 = .0000000000000000000542101086242752217003726400434970855712890625 becomes zero when copied into a coördinate of a position object. Let’s try that value and the previous double value, which is

253 − 1 253 · 2–64 = 2–64 – 2–117 local good = celestia:newposition(2^-64, 0, 0) local bad = celestia:newposition(2^64 - 2^-117, 0, 0) local s = string.format( "good = (%.45g, %.45g, %.45g)\n" .. "bad = (%.45g, %.45g, %.45g)", good:getx(), good:gety(), good:getz(), bad:getxyz()) good = (5.42101086242752217003726400434970855712890625e-20, 0, 0) bad = (0, 0, 0)

In each case, the C++ function celestia_newposition in version/src/celestia/celx.cpp calls the constructor for the C++ class BigFix whose argument is a double in version/src/celutil/bigfix.cpp. The constructor leaves the position coördinate uninitialized on overflow. Underflow just goes to zero.

Get the PositionObject:getposition of a Celestial Object

We can get the position of a celestial object at the current simulation time, or at any other time.

--[[
Print the position of the Sun, today and tomorrow.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	local sol = celestia:find("Sol")
	local today = sol:getposition()		--at current simulation time
	assert(type(today) == "userdata" and tostring(today) == "[Position]")

	local s = string.format("today    = (%.15g, %.15g, %.15g)\n",
		today:getxyz())

	local t = celestia:gettime() + 1
	local tomorrow = sol:getposition(t)	--at simulation time t
	assert(type(tomorrow) == "userdata"
		and tostring(tomorrow) == "[Position]")

	s = s .. string.format("tomorrow = (%.15g, %.15g, %.15g)",
		tomorrow:getxyz())

	celestia:print(s, 60)
	wait()
end

main()
today = (-0.0175211821017193, -0.000726568584015948, 0.0393328537482719) tomorrow = (-0.0174232553486754, -0.000728713770735825, 0.0393491020003413)

How many kilometers will the Sun move in the next 24 hours with respect to the universal frame?

local kilometers = today:distanceto(tomorrow) --not microlightyears kilometers = 939.344131605354

How many kilometers will MercuryMercury, orbital speed of and VenusVenus, orbital speed of Venus move? What about Halley’s CometHalley’s Comet, orbital speed of at its perihelion on February 9, 1986?

Does JupiterJupiter, effect on Sun of bear the lion’s share of the responsibility for displacing the Sun from the Solar System Barycenter? Find the direction from the barycenter to the Sun, expressed in degrees counterclockwise in the XZ plane from the positive X axis.

local sol = celestia:find("Sol") local position = sol:getposition() local longlat = position:getlonglatPosition:getlonglat() local s = string.format("%-7s %.15g%s", sol:name(), math.deg(longlat.longitude), std.char.degree)

Similarly, find the direction from the barycenter to Jupiter. Are the directions approximately 180° apart? Does SaturnSaturn, effect on Sun of make a contribution?

Sol 219.097719310638° Jupiter 56.0906261072159°

A body of negligible size responding to the forces of Newtonian physics is called a particleparticle (in Newtonian physics). A particle falling near the surface of the Earth picks up 9.80665 meters per second of velocity during each second that it falls. This gravitational accelerationgravitational acceleration (g) is written with a lowercase gg (gravitational acceleration), and the s2 in the denominator means “per second per second”. g = 9.80665m s2 Therefore after t seconds, the particle is falling at a velocity of V(t) = gt meters per second. Integrating V(t) with respect to t, we find that the distance in meters fallen during the first t seconds is d(t) = 1 2 gt2 For example, the particle covers approximately 4.9 meters during the first second and 19.6 meters during the first two seconds. Another example of finding distance by integration is in Exponential.

Let’s see how well this simple expression d(t) approximates the true distance fallen by the particle. To postpone the moment when the particle hits the ground, we will assume that the mass of the Earth has been crushed into a point at its center. The particle is now free to move from the original surface of the Earth to the center of the Earth along an infinitely narrow, degeneratedegenerate ellipse ellipticalellipse, degenerate orbit—just a straight line.

To create a celestial object named Particle that moves along this orbit, create a file named imaginary.ssc containing the following. Place it in the data subdirectory of the Celx directory (celxDirectory).

#This file is data/imaginary.ssc.

"Particle" "Sol/Earth"
{
	Radius 0

	EllipticalOrbit {
		Epoch		0			#January 1, 4713 B.C.
		Period		.0206929593174828	#in Earth days (30 min.)
		SemiMajorAxis	3183.725		#in kilometers
		Eccentricity	.999999999999999	#wish it could be 1
		MeanAnomaly	180			#at apogee at time t = 0
	}
}

Insert the new filename "data/imaginary.ssc" after the existing "data/solarsys.ssc" in the list of SolarSystemCatalogsSolarSystemCatalogs (parameter in celestia.cfg) in the configuration file celestia.cfgcelestia.cfg (configuration file), and restart Celestia.

Where did the above numbers come from? I wanted the eccentricity to be exactly 1 for a totally degenerate ellipse that is just a straight line. But this would cause the C++ member function EllipticalOrbit::positionAtE in version/src/celengine/orbit.cpp to return a position of (0, 0, 0) at all times, so we had to settle for an almost degenerate ellipse. The major axis of the particle’s elliptical orbit is the radius of the Earth; the semi-major axis is half the radius. Our value for the radiusEarth, radius of was the average of the equatorial and polar radii from the Earth article in Wikipedia (6378.1 and 6356.8 km). The orbital period P in seconds is determined by Kepler’s Third LawKepler’s third law,

P2 a3 = 4π2 MG

where a is the semi-major axis in meters, M is the mass of the EarthEarth, mass ofin kilograms (approximately 5.9736 · 1024, from the same Wikipedia article), and GG (gravitational constant) is the gravitational constantgravitational constant (G) (approximately 6.672 · 10–11 m3 kg–1 s–2, from version/src/celengine/astro.cpp). Given these values, the particle’s orbital period P in seconds works out to approximately 30 minutes.

The standard value of the gravitational acceleration g, 9.80665 meters per second per second, is the sum of a positive term resulting from the force of gravity and a negative term from the Earth’s rotation. The following program ignores the Earth’s rotation, so it removes the negative term from the acceleration. We multiply by 1000 to convert kilometers to meters, and divide by 60 · 60 · 24 to convert seconds to days.

--[[
How many meters does a falling particle travel each second?
]]
require("std")

--variables used by both functions
local earth = celestia:find("Sol/Earth")
local particle = celestia:find("Sol/Earth/Particle")

--Return the distance in kilometers from the center of the Earth
--to the center of the Particle at time t.

local function distance(t)
	return earth:getposition(t):distanceto(particle:getposition(t))
end

local function main()
	--Keep the Sun in view, but get away from the glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	local earthRadius = (6378.1 + 6356.8) / 2	--in km, from Wikipedia
	local semiMajorAxis = earthRadius / 2
	local negativeTerm = -1000 * earthRadius
		* (2 * math.pi / (60 * 60 * 24))^2 / math.sqrt(2)
	local acceleration = 9.80665 - negativeTerm	--in m per s^2

	local t0 = 0	--time when particle is at apogee
	local s = " t     distance       .5gt^2        error\n"

	for i = 0, 10 do		--second by second
		local t = t0 + i / (60 * 60 * 24)	--i seconds after t0

		--distances in meters:
		local ellipse = 1000 * math.abs(distance(t) - distance(t0))
		local parabola = .5 * acceleration * i^2

		s = s .. string.format("%2d   %10.6f   %10.6f   %10.6f\n",
			i, ellipse, parabola, math.abs(ellipse - parabola))
	end

	celestia:print(s, 60, -1, 1, 1, -1)
	wait()
end

main()
t distance .5gt^2 error 0 0.000000 0.000000 0.000000 1 4.915106 4.915231 0.000124 2 19.660437 19.660923 0.000486 3 44.236026 44.237076 0.001050 4 78.641975 78.643690 0.001716 5 122.878368 122.880766 0.002398 6 176.945346 176.948303 0.002957 7 240.843072 240.846301 0.003230 8 314.571760 314.574761 0.003001 9 398.131627 398.133682 0.002055 10 491.522937 491.523064 0.000127

Average the Positions of Many Objects

For many years astronomers have known that the Milky WayMilky Way, location of center of is shaped like a pancake. But no one knew where its center was, since the plane of the galaxy is full of stars, gas, and dust. It was Harlow ShapleyShapley, Harlow who solved this problem by plotting the positions of the globular clustersglobular clusters and Milky Way above and below the galactic plane. He assumed they formed a spherical halo around the Milky Way, and located the center of the galaxy by locating the center of the halo. See the bibliography.

Let’s try this with Celestia. For now, we will merely compute the average position of the clusters. In alternativeBodyfixed, we will actually see them.

--[[
Print the average of the positions of the globular clusters that surround
the Milky Way.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	local sum = std.position0	--(0, 0, 0)
	local i = 0

	--Assume all globulars belong to the Milky Way.
	for dso in celestia:dsos() do
		if dso:type() == "globular" then
			i = i + 1
			sum = sum + dso:getposition()
		end
	end

	if i == 0 then
		celestia:print("No globular clusters found.", 10)
		wait()
		return
	end

	local average = sum / i
	local milkyWay = celestia:find("Milky Way")
	local center = milkyWay:getposition()

	local kilometersError = center:distanceto(average)
	local kilometersRadius = milkyWay:radius()

	local s = string.format(
		"Actual center = (%.15g, %.15g, %.15g)\n"
		.. "Computed center = (%.15g, %.15g, %.15g)\n"
		.. "Error = %.15g kiloparsecs = %.15g lightyears\n"
		.. "Radius of Milky Way = %.15g kiloparsecs = %.15g lightyears",

		center:getx(), center:gety(), center:getz(),
		average:getx(), average:gety(), average:getz(),
		kilometersError / (1e3 * std.kmPerPc),
		kilometersError / (1e6 * KM_PER_MICROLY),
		kilometersRadius / (1e3 * std.kmPerPc),
		kilometersRadius / (1e6 * KM_PER_MICROLY))

	celestia:print(s, 60)
	wait()
end

main()

The computed center is only 2 or 3 kiloparsecs from the true center. Not bad, considering that the Milky Way is 30 kiloparsecs in diameter.

Actual center = (-1521500000, -2674310546.875, 27548710937.5) Computed center = (-5755394086.71061, -3381279543.22815, 20043705537.0077) Error = 2.65083051399512 kiloparsecs = 8645.85278389049 lightyears Radius of Milky Way = 15.3300699302482 kiloparsecs = 50000.000786137 lightyears

The position objects share a table of methods called the Lua metatablemetatable (Lua) for class PositionPosition, metatable of. The division operator in the above expression sum / i calls the __div__div (in metatable) method (two underscores) in the metatable, which calls the __mul__mul (in metatable) function in the metatable. These functions were created by the Celx Standard Library to make it possible to apply the operators ** (position multiplication operator) and // (position division operator) to a position object. Read the functions in std.lua (stdCelx).

AccessPosition encoded as string the 128 Bits of a Coördinate

A position contains three coördinates, each in the 128-bit format we saw in positionCoordinates. These 128 bits consist of 16 bytes of 8 bits each. The bytes are numbered from 0 to 15 (least to most significant). The bits within each byte are numbered from 0 to 7 (least to most significant).

The value of the coördinate is a binary number with 64 bits to the left of the binary point and 64 bits to the right. Bytes 8 through 15 are the integer, bytes 0 though 7 are the fraction, and the binary point is fixed in the middle.

Unfortunately, these 128-bit values are far too wide to fit into the arguments of Celestia:newposition or the return values of Position:getx, gety, and getz. A Celx number can hold only 64 bits; see doubleArithmetic. But a Celx program can read and write the 128-bit values indirectly. Instead of passing three numbers to Celestia:newpositioncelestia:newposition, we can pass it three strings. Each character in the strings represents six bits, and the entire string represents 128 bits. The following characters are used.

  1. Uppercase letters:
    "A" represents 000000 (0)
    "B" represents 000001 (1)
    etc.
    "Z" represents 011001 (25)
  2. Lowercase letters:
    "a" represents 011010 (26)
    "b" represents 011011 (27)
    etc.
    "z" represents 110011 (51)
  3. Decimal digits:
    "0" represents 110100 (52)
    "1" represents 110101 (53)
    etc.
    "9" represents 111101 (61)
  4. Miscellaneous:
    "+" represents 111110 (62)
    "/" represents 111111 (63)

Each string passed to Celestia:newposition starts with the least significant byte and ends with the most significant. Within each byte, we start with the most significant bit and end with the least significant. Thus a string begins with the most significant bit of the least significant byte. The empty string represents zero. Blanks are ignored and can be used for legibility.

The three strings in the following program represent the coördinates (–1, 0, 1). In the string representing 1, the first ten A’s represent 60 of the 64 bits of the fraction: bytes 0 through 6 inclusive, and the four most significant bits of byte 7. The eleventh A straddles the four least significant bits of byte 7 and the two most significant bits of byte 8. The B, representing 000001, is the six least significant bits of byte 8. The remaining 56 bits default to zeroes.

The value –1 is represented by a fraction of 64 zeroes in bytes 0 through 7, and an integer of 64 ones in bytes 8 through 15. As before, the ten A’s represent bytes 0 through 6 inclusive, and the four most significant bits of byte 7. The D, representing 000011, straddles the four least significant bits of byte 7 and the two most significant bits of byte 8. The ten slashes represent the middle 60 bits of the integer, all ones. The lowercase w, representing 110000, is the two least significant bits of byte 15, followed by four unused bits.

--[[
Create a position from three strings.
]]
require("std")

local function main()
	--The Sun will be out of the window to the observer's right.
	local position = celestia:newposition(
		"AAAA AAAA AAD/ //// //// /w",	--represents -1
		"",				--represents  0
		"AAAA AAAA AAAB")		--represents  1

	assert(type(position) == "userdata"
		and tostring(position) == "[Position]")

	local observer = celestia:sane()
	observer:setposition(position)
	local s = string.format("(%.15g, %.15g, %.15g)", position:getxyz())
	celestia:print(s, std.duration)
	wait()
end

main()
(-1, 0, 1)

An easier way to compose the three strings is with the function std.tourlstd.tourl. It accepts three string arguments that look like plain old decimal numbers, each with an optional minus sign and optional decimal point but no exponent. It returns three strings that encode the numbers in the format passed to Celestia:newposition.

Did it work? The standard library methods Position:getbinaryPosition:getbinary, getdecimalPosition:getdecimal, and gethexPosition:gethex return tables of strings spelling out the coördinates in bases 2, 10, and 16 respectively. Also, the method Position:geturlPosition:geturl is the converse of std.tourl. It returns a table of strings that could be passed to Celestia:newposition. This format could also be used in a Celestia cel: URL.

--[[
Write and read all 128 bits of the coordinate values of a position.
]]
require("std")

local function main()
	local observer = celestia:sane()
	observer:setposition(std.position)

	local x, y, z = std.tourl(
		"1234567890123456789.1234567890123456789",
		"1",
		"-1")
	local s = string.format(
		"String encoding:\n"
		.. "(\"%s\", \"%s\", \"%s\")\n\n",
		x, y, z)

	local position = celestia:newposition(x, y, z)
	s = s .. string.format(
		"64-bit double values:\n"
		.. "(%.15g, %.15g, %.15g)\n\n",
		position:getxyz())

	local hex = position:gethex()
	s = s .. string.format(
		"128-bit values in hexadecimal:\n"
		.. "x = %s\n"
		.. "y = %s\n"
		.. "z = %s\n\n",
		hex.x, hex.y, hex.z)

	local decimal = position:getdecimal()
	s = s .. string.format(
		"128-bit values in decimal:\n"
		.. "x = %s\n"
		.. "y = %s\n"
		.. "z = %s\n\n",
		decimal.x, decimal.y, decimal.z)

	local url = position:geturl()
	s = s .. string.format(
		"Back to strings:\n"
		.. "(\"%s\", \"%s\", \"%s\")",
		url.x, url.y, url.z)

	celestia:print(s, math.huge, -1, 1, 1, -1)	--upper left
	wait()
end

main()

The value 1234567890123456789.1234567890123456789 is a repeating fraction in binary, like ⅓ in decimal. It therefore cannot be stored in a finite number of bits. But the value that we have stored in the x coördinate is as close as we can get to it with the 128 bits at our disposal. Rounded to the nearest 10–19, it would give us the desired value exactly.

String encoding: ("HF/2Rjfdmh8Vgel99BAiEQ", "AAAAAAAAAAAB", "AAAAAAAAAAD//////////w") 64-bit double values: (1.23456789012346e+18, 1, -1) 128-bit values in hexadecimal: x = 112210F47DE981151F9ADD3746F65F1C y = 00000000000000010000000000000000 z = FFFFFFFFFFFFFFFF0000000000000000 128-bit values in decimal: x = 1234567890123456789.12345678901234567888776927357952217789716087281703948974609375 y = 1 z = -1 Back to strings: ("HF/2Rjfdmh8Vgel99BAiEQ", "AAAAAAAAAAAB", "AAAAAAAAAAD//////////w")

Look at the method Position:getbinary in std.lua (stdCelx) and see how it reads the 128 bits of a coördinate. But first, we must understand how a negative number is stored in the fixed point format. Most machines would use the two’s complement representationtwo’s complement format, modeled on an odometer running backwards:

0003
0002
0001
0000
9999 represents –1
9998 represents –2
9997 represents –3
etc.

In binary, the moral equivalent of 9999 is 1111:

0011
0010
0001
0000
1111 represents –1
1110 represents –2
1101 represents –3
etc.

The most significant bit of a coördinate—the one on the far left—is the most significant bit of byte 15. It is 1 for a negative number, 0 for a nonnegative, and is therefore called the sign bitsign bit (two’s complement). We can get the value of the sign bit simply by testing whether the number is negative or nonnegative.

How can we examine the other 127 bits? We have seen that we can add two positions together to get a new position (averagePosition). In particular, we can add a position to itself, thus doubling the value of each coördinate. Doubling a binary number shifts all of its bits one place to the left, and repeated doublings cause repeated shifts. One by one, each of the 128 bits can be shifted into the position of the sign bit, where its value can be examined. For another example of shifting, see the implementation of the function std.utf8.

Verify that all but 15 significant decimal digits are lostprecision, loss of when multiplying a position by a Celx number. This is because the __mul function in the Position metatable is implemented in Celx, causing its argument to be a 64-bit Celx number. --Move twice as far away from the Solar System Barycenter. position = 2 * position The following code will perform the operation without any loss of precision. This is because the __add__add (in metatable) function in the Position metatable is implemented in C++, allowing its argument to be a 128-bit fixed point number. --Move twice as far away from the Solar System Barycenter. position = position + position

VectorsVector (class)

A vector object holds the distance and direction between two positions. Think of it as an arrow pointing from one position to the other. Like a position, a vector has the fields x, y, z and the methods getxVector:getx, getyVector:gety, getzVector:getz, and getxyzVector:getxyz, but the values are plain, ordinary doubles (doubleArithmetic). The unit of distance is the microlightyear (linearDistance).

The Distance Between Two Positions

The following program shows three ways to create the same vector. Each one is an arrow pointing from the source position (the Sun) to the destination position (the Earth). Since the Sun and Earth are in constant motion, we must get their positions at the same simulation time. It’s easy to accomplish this: just make no call to waitwait between the two calls to Object:getpositionObject:getposition.

The method Vector:normalizeVector:normalize returns a unit vectorunit vector—one whose length is 1—pointing in the same direction as the original vector. Only a vector of positive length can be normalizednormalize a vector, on pain of division by zero. But see the fine print in the exercises below.

--[[
Create three vectors, measure their lengths, and normalize them.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	local sol = celestia:find("Sol")
	local earth = celestia:find("Sol/Earth")
	local source = sol:getposition()
	local destination = earth:getposition()

	--Three ways to create the same vector,
	--pointing from source to destination.

	local v1 = celestia:newvector(
		destination:getx() - source:getx(),
		destination:gety() - source:gety(),
		destination:getz() - source:getz())

	local v2 = destination - source
	local v3 = source:vectortoPosition:vectorto(destination)

	local s = string.format(
		"v1 = (%.15g, %.15g, %.15g)\n"
		.. "v2 = (%.15g, %.15g, %.15g)\n"
		.. "v3 = (%.15g, %.15g, %.15g)\n\n"
		.. "v1:length() = %.15g",

		v1:getx(), v1:gety(), v1:getz(),
		v2:getx(), v2:gety(), v2:getz(),
		v3:getx(), v3:gety(), v3:getz(),
		v1:lengthVector:length())

	if v1:length() > 0 then
		local v4 = v1:normalize()
		s = s .. string.format(
			"\n"
			.. "v4 = (%.15g, %.15g, %.15g)\n"
			.. "v4:length() = %.15g",
			v4:getx(), v4:gety(), v4:getz(),
			v4:length())
	end

	celestia:print(s, 60, -1, 1, 1, -1)
	wait()
end

main()
v1 = (-11.2082594771348, -0.000301974087853152, -10.8365831649699) v2 = (-11.2082594771348, -0.000301974087853152, -10.8365831649699) v3 = (-11.2082594771348, -0.000301974087853152, -10.8365831649699) v1:length() = 15.5902730986112 v4 = (-0.718926436133647, -1.93693905131175e-05, -0.695086166639072) v4:length() = 1

The above distances are in microlightyears. The distance returned by the following method Position:distancetoPosition:distanceto is in kilometers.

--[[
Get the distance between the Sun and Earth in microlightyears, kilometers,
and astronomical units.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	local sol = celestia:find("Sol"):getposition()
	local earth = celestia:find("Sol/Earth"):getposition()
	local microlightyears = (sol - earth):length()
	local kilometers = sol:distanceto(earth)

	local s = string.format(
		"Current distance from Sun to Earth:\n"
		.. "%.15g microlightyears\n"
		.. "%.15g kilometers\n"
		.. "%.15g astronomical units",
		microlightyears,
		kilometers,
		microlightyears / std.microlyPerAu)

	celestia:print(s, 60)
	wait()
end

main()
Current distance from Sun to Earth: 15.5902730986112 microlightyears 147495371.779888 kilometers 0.985945662794036 astronomical units

If we try to normalize a vector of length zero, Celestia will divide by zero. The road to ruin starts at the Celx method Vector:normalize, which calls the C++ function vector_normalize in version/src/celestia/celx_vector.cpp, which calls the C++ member function Vector3<T>::normalize in version/src/celmath/vecmath.h, which performs the fatal division. The C++ Standard says “the behavior is undefined” when dividing by zero, which means anything can happen. My machine gave me a vector of three “not a numbers”not a number (nan). It seems to be taunting me:

v = (0, 0, 0) v:length() = 0 v:normalize() = (nannan (not a number), nan, nan)

What happens on your machine? Microsoft Windows prints “not a number” as the indeterminate symbol -1.#IND.

Even if a vector has one or more nonzero coördinates, the methods Vector:length and Vector:normalize might still think its length is zero. This is because the length of a vector v is computed as follows.

||v|| = x2 + y2 + z2

Assuming our doubles have the standard 53-bit mantissa, the smallest positive double is the following denormalized value (denormalized).

1 253 · 2–1021 = 2–1074 ≈ 4.94065645841247 · 10–324

A small number gets even smaller when squared. The smallest positive double whose square is nonzero is

2 · 2–538 6,369,051,672,525,773 253 · 2–537

Its square is 2–1075, which is rounded up to 2–1074.

local x = math.sqrt(2) * 2^-538 --its square is smallest positive double local y = x - (1 / 2^53) * 2^-537 --the previous double value --local y = std.prevstd.prev(x) --easier way to get the same value local s = string.format( "x = %.17g, x * x = %.15g\n" .. "y = %.17g, y * y = %.15g", x, x * x, y, y * y) x = 1.5717277847026288e-162, x * x = 4.94065645841247e-324 y = 1.5717277847026285e-162, y * y = 0

So a vector all of whose coördinates have an absolute value less than 2 · 2–538 will have a computed length of zero, causing Vector:normalize to behave unpredictably.

local x = math.sqrt(2) * 2^-538 local y = std.prev(x) local v = celestia:newvector(y, y, y) local w = v:normalize() local s = string.format( "v = (%.15g, %.15g, %.15g)\n" .. "v:length() == %.15g\n" .. "w = (%.15g, %.15g, %.15g)", v:getx(), v:gety(), v:getz(), v:length(), w:getxyz()) v = (1.57172778470263e-162, 1.57172778470263e-162, 1.57172778470263e-162) v:length() == 0 w = (infinfinity, inf, inf)

The Direction From One Position to Another

Let’s get an upright view of the constellation CygnusCygnus the Swan, a.k.a. the Northern CrossNorthern Cross (Cygnus). The observer will look towards the star SadrSadr (star) (γ Cygni, the chest of the swan). At the top of the picture will be DenebDeneb (star) (α, the tail); at the bottom, AlbireoAlbireo (β, the head).

The observer will be oriented with Observer:lookatObserver:lookat. Its next-to-last argument is the target position to look at, always in universal coördinates. The vector from the observer’s position to the target position will be the observer’s forward vectorforward vector of orientation, pointing in the direction in which he will look.

The last argument of Observer:lookat is the up vectorup vector of orientation, also in universal coördinates. It points towards the observer’s zenith, the direction he thinks is up. With our up vector of deneb:getposition() - albireo:getposition(), the observer will be oriented so that Deneb appears directly above Albireo.

The forward and up vectors are supposed to be perpendicular. If they are not, the forward vector takes precedence and the up vector is adjusted to accommodate it. The new up vector is exactly perpendicular to the forward vector, and lies in the plane of the forward vector and the original up vector. It is always less than 90° from the original up vector. We say that the up vector has been erectederect a vector; see Vector:erect in erect.

Observer:lookat has an optional third-from-last argument; see parallax. If you prefer the LatinLatin language genitive casegenitive case (Latin) to the ArabicArabic language nominativenominative case (Arabic), you can specify the stars by their Bayer designationsBayer designation (of star): "Alpha Cygni", "Alpha Cyg", or even std.char.alpha .. " Cyg". To highlight the constellation, we double the Renderer::ConstellationColorRenderer::ConstellationColor (C++ constant) in version/src/celengine/render.cpp.

--[[
Look at Cygnus the Swan, positioning Deneb above Albireo.
The Albireo-to-Deneb vector points straight up.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	celestia:show("boundaries")	--constellation boundaries
	observer:setfov(math.rad(46))	--wide enough to see whole constellation
	celestia:setconstellationcolor(2 * 0, 2 * .24, 2 * .36, {"Cygnus"})

	local deneb = celestia:find("Deneb")		--Alpha, tail of swan
	local sadr = celestia:find("Sadr")		--Gamma, chest
	local albireo = celestia:find("Albireo")	--Beta, head

	local up = deneb:getposition() - albireo:getposition()	--up is a vector
	observer:lookat(sadr:getposition(), up)
	wait()
end

main()

To flip the observer upside down, we could use the opposite up vector. Here are two ways to negate it:

local up = albireo:getposition() - deneb:getposition() observer:lookat(sadr:getposition(), up) local up = deneb:getposition() - albireo:getposition() observer:lookat(sadr:getposition(), -up) --unary minus

For the fun-loving programmer, Observer:lookat offers four ways of dividing by zerodivision by zero. In each case, the division is attempted when the C++ function observer_lookat in version/src/celestia/celx_observer.cpp calls the C++ member function Vector3<T>::normalize in version/src/celmath/vecmath.h. On my machine, the Universe went black. What happens on yours?

--The forward vector must not be of length zero. observer:lookat(observer:getposition(), deneb:getposition() - albireo:getposition()) --The up vector must not be of length zero. observer:lookat(sadr, deneb:getposition() - deneb:getposition()) --The forward and up vectors must not be colinearcolinear vectors, i.e., --they must not point in the same direction ... observer:lookat(sadr:getposition(), sadr:getposition() - observer:getposition()) --... or in opposite directions. observer:lookat(sadr:getposition(), observer:getposition() - sadr:getposition())
And in the mind of Studs Lonigan, through an all-increasing blackness, streaks of white light filtered weakly and recessively like an electric light slowly going out. And there was nothing in the mind of Studs Lonigan but this feeble streaking of light in an all-encompassing blackness, and then, nothingbrain death.
James T. FarrellFarrell, James T., Studs LoniganStuds Lonigan (Farrell): Judgement DayJudgement Day (Farrell) (1934)

Conversion Between a Position and a Vector

A position and a vector contain the same Cartesian coördinates, so the standard library provides conversion functions.

local earth = celestia:find("Sol/Earth") local position = earth:getposition() --Convert the position to a vector pointing --from the origin (the Solar System Barycenter) to the Earth. local vector = position:tovectorPosition:tovector() --Convert the vector back to the Earth's position. position = vector:topositionVector:toposition()

Conversion from position to vector will usually lose precisionprecision, loss of because a coördinate occupies 128 bits in a position but only 64 in a vector. The method Position:tovector accepts this loss as a fact of life and does not warn about it. On the other hand, the method Vector:toposition calls the Lua function errorerror (Lua function) if the absolute value of a vector coördinate is greater than or equal to 263. In this case, the conversion would have resulted in garbage (observerSetposition).

If a program does not require the Celx Standard Library, positions and vectors will be handled very differently. If the program does use the library, positions and vectors will be more interchangeable and there will be fewer reasons to call the conversion methods. The following are the major examples.

  1. Without the library, we can multiply a vector by a number. With the library, we can also multiply a position by a number. See averagePosition.
  2. Without the library, the method Rotation:transformRotation:transform (transformVector) will accept a vector argument. With the library, it will also accept a position.
  3. Without the library, the methods Frame:fromFrame:from and Frame:toFrame:to (eclipticFrame) will accept a position argument. With the library, they will also accept a vector.

But even with the library, there will still be some differences between positions and vectors. The methods Observer:lookat (direction) and Position:orientationto (convertForward) accept only a position as their first argument, and only a vector as their second. The dot product (dotProduct) and cross product (crossProduct) operations can be performed only on vectors. The sum of a position and a vector is always a position, and the difference of two positions or of two vectors is always a vector.

Verify that Vector:toposition warns about overflow but not underflow. Verify that Position:tovector usually results in loss of precision.

local position = celestia:newposition(std.tourl( "1234567890123456789", ".1234567890123456789", "1234567890123456789.1234567890123456789" )) local decimal = position:getdecimal() local s = string.format("%s\n%s\n%s\n\n", decimal.x, decimal.y, decimal.z) local vector = position:tovector() s = s .. string.format("%.15g\n%.15g\n%.15g\n\n", vector:getxyz()) position = vector:toposition() decimal = position:getdecimal() s = s .. string.format("%s\n%s\n%s", decimal.x, decimal.y, decimal.z)

After converting the position to a vector and back again, the x coördinate lost the last two decimal digits of its integral part and the y coördinate lost the last two decimal digits of its fractional part. The z coördinate lost the last two decimal digits of its integer and all of its fraction.

1234567890123456789 0.12345678901234567888776927357952217789716087281703948974609375 1234567890123456789.12345678901234567888776927357952217789716087281703948974609375 1.23456789012346e+18 0.123456789012346 1.23456789012346e+18 1234567890123456768 0.12345678901234567736988623209981597028672695159912109375 1234567890123456768

Dot Productdot product and the Angle Between Two Vectors

The dot product of two vectors v and w is a number from which we can extract the angleangle between vectors between the vectors. The dot product is written as v ** (dot product operator) w in Celx and as v · w in mathematics. (The raised dot character is std.char.middotstd.char.middot.) The value of the dot product is v · w = ||v|| ||w|| coscosine (trig function) θ where ||v|| and ||w|| are the lengths of the vectors (distancePositions), and θ is the angle between the vectors. (The Greek letter theta is std.char.theta.) The dot product operation is commutativecommutativity of dot product and associativeassociativity of dot product because multiplication is commutative and associative. Its value is zero if the vectors are perpendicular, because cos 90° = 0. It is also zero if either vector is of length zero.

If both vectors are of positive length, there is a meaningful angle θ between them. We can solve for the cosine of this angle, cos θ = v · w ||v|| ||w|| and then for the angle itself. Arccosarccosine (trig function) means “the angle whose cosine is”. θ = arccos v · w ||v|| ||w||

In Celx we could compute the angle as follows. The first asterisk is a plain old multiplication of two numbers; the second asterisk is a dot product of two vectors.

--[[ v and w must be nonzero vectors. theta is the angle between them in radians, in the range 0 to pi inclusive. ]] local product = v:length() * w:length() assert(product > 0) local theta = math.acosmath.acos(v * w / product) We can write this more simply with the standard library method Vector:angleVector:angle, analogous to Position:distanceto. It calls the Lua function errorerror (Lua function) if either vector is of length zero. --[[ v and w must be nonzero vectors. theta is the angle between them in radians, in the range 0 to pi inclusive. ]] local theta = v:angle(w) --Same value: the order doesn't matter. --local theta = w:angle(v)

Warning: math.acos and Vector:angle return an angle in the range 0 to π radians inclusive. If we need an angle in a bigger range such as 0 to 2π, or π to π, we’ll have to compute it ourselves with math.atan2math.atan2. The most straightforward examples are in Position:getlonglatPosition:getlonglat and Vector:getlonglatVector:getlonglat.

Vectors to Two Stars at the Same Time

According to an old chestnut of astronomy, the pointersPointers (of Big Dipper) of the Big DipperBig Dipper are five degrees apart. Let’s see if it’s true. Their names are DubheDubhe (star) and MerakMerak (star), a.k.a. "Alpha" and "Beta Ursae Majoris". We’ll face towards Megrez ("Delta", the star that joins the handle to the bowl), with the north pole of the ecliptic towards the top of the window.

--[[
Look at the Big Dipper, with Draco overhead.  Print the angular distance
between the pointers, as seen from the Solar System Barycenter.
]]
require("std")

local function main()
	local observer = celestia:sane()
	observer:setposition(std.position)  --Avoid Sol's glare.
	celestia:setconstellationcolor(2 * 0, 2 * .24, 2 * .36, {"Ursa Major"})

	--Dubhe and Merak are the pointers of the Big Dipper.
	local dubhe = celestia:find("Dubhe")	--Alpha Ursae Majoris
	local merak = celestia:find("Merak")	--Beta
	local megrez = celestia:find("Megrez")	--Delta, joins bowl to handle
	dubhe:mark()
	merak:mark()

	--The Y axis of the universal frame points from the Solar System
	--Barycenter towards the north pole of the ecliptic in Draco.
	observer:lookat(megrez:getposition(), std.yaxis)

	--The vectors toDubhe and toMerak point from the Solar System Barycenter
	--to Dubhe and Merak.
	local toDubhe = dubhe:getposition():tovector()
	local toMerak = merak:getposition():tovector()

	local theta = toDubhe:angle(toMerak)	--in radians
	local az = std.toaz(theta)	--convert to degrees, minutes, seconds

	local s = string.format(
		"Seen from our Solar System,\n"
		.. "the pointers of the Big Dipper "
		.. "are %d%s %d%s %.15g%s apart.",
		az.degrees, std.char.degree,
		az.minutes, std.char.minute,
		az.seconds, std.char.second)

	celestia:print(s, 60)
	wait()
end

main()
Seen from our Solar System, the pointers of the Big Dipper are 5° 22′ 27.2320068968585″ apart.

The Winter TriangleWinter Triangle consists of the stars SiriusSirius (star), ProcyonProcyon (star), and BetelgeuseBetelgeuse (star). How equilateral is it? Measure the apparent angular distance between Sirius and Procyon, Procyon and Betelgeuse, etc. What about the Great SquareGreat Square of Pegasus of PegasusPegasus?

How high above the horizon is the Sun at 17:00 UTC (Noon in Eastern Standard Time) on January 1, 2014 as seen from "Sol/Earth/Washington D.C."? (One space, no comma, two periods.) Or instead of Washington, you can add your own city to Celestia’s data/world-capitals.ssc file, giving it an appropriate level of Importance. Then restart Celestia.

Create a vector from the center of the Earth to Washington, and a vector from Washington to the center of the Sun. The angle θ between the vectors is the Sun’s angular distance down from Washington’s zenithzenith. The complementary anglecomplementary angles 90° − θ is the Sun’s angular distance up from Washington’s horizon. (Be careful not to mix radians with degrees.) The Sun should be about 28° up from the horizon.

Is the Sun lower in the sky an hour later? By trial and error, find the time when the center of the sun sets. Better yet, find when the upper limb (edge) of the Sun sets. In whenSunset, the Celx program will do the trial and error automatically. And in altazimuthCoordinates, we’ll have an easier way to measure the angles.

How high above the lunar horizon was the Sun at 20:17:40 UTC on July 20, 1969Apollo 11 as seen from "Sol/Earth/Moon/Mare Tranquillitatis"Mare Tranquillitatis (lunar sea)? (One space after the Mare; it’s pronounced “mah-ray”.) The answer should be about 19°. EagleApollo Lunar Module, you are Stay for T1.

Seen from the Earth, MercuryMercury, elongation of is usually lost in the glare of the Sun. The planet is easiest to see when it is at the greatest elongationelongation (of Mercury) (angular distance) from the Sun. What is Mercury’s elongation on September 21, 2014 at 18:00 UT? Create vectors from the Earth to the Sun, and from the Earth to Mercury. The answer should be about 26°.

local earth = celestia:find("Sol/Earth") local mercury = celestia:find("Sol/Mercury") --vector from Earth to Mercury local toMercury = mercury:getposition() - earth:getposition()

Print a table showing the angular distance between the Sun and Mercury at 18:00 UTC on each day from September 18 to September 24, 2014 inclusive. On which day does Mercury reach its greatest elongation? To zero in on the exact time, see mercuryPerihelion.

local mercury = celestia:find("Sol/Mercury") --first and last are whole numbers because of the 12s. local first = celestia:tojulianday(2014, 9, 18, 12) local last = celestia:tojulianday(2014, 9, 24, 12) for day = first, last do local utc = celestia:fromjulianday(day) local t = celestia:utctotdb(utc.year, utc.month, utc.day, 18) local mercuryPosition = mercury:getposition(t) --etc. end
Very soon the heavens presented an extraordinary appearance, for all the stars directly behind me were now deep redredshift, while those directly ahead were violetblue shift. Rubies lay behind me, amethysts ahead of me.
Olaf StapledonStapledon, Olaf, Star MakerStar Maker (Stapledon) (1937)

The cosmic microwave background radiationcosmic microwave background radiation comes at us from every direction. The Earth ploughs through this radiation at several hundred kilometers per second, heading in the direction of RA 11h 11m 57s Dec –7.22° in the constellation LeoLeo. The Doppler effectDoppler effect causes the radiation coming from Leo to seem hotter, and that from Aquarius, on the other side of the sky, to seem colder.

Let’s paint the whole sky to show the different temperatures. The vector toHot points towards the hot spot in Leo, and toStar points towards each star in the sky. The angle between them determines the color with which we mark the star. See rotatedRadec for the radecFrame.

Normally a program is interrupted if it does not call waitwait at least once every five seconds. Our loop will take much longer than this, so we lengthen the interval with Celestia:settimesliceCelestia:settimeslice.

--[[
Color the sky to show the dipole in the Cosmic Microwave Background: hot violet
around our destination in Leo, cold red in Aquarius on the other side of the
sky.  Drag on the sky or press the arrow keys.
]]
require("std")

local function main()
	local observer = celestia:sane()
	observer:setposition(std.position0)
	observer:setfov(observer:getfov() * 4 / 3)   --see entire violet circle

	--[[
	The Earth is moving towards RA 11h 11m 57s Dec -7.22 degrees in Leo.
	The radiation looks hotter there.
	It's moving away from the point on the other side of the sky.
	]]
	local toCold = celestia:newvectorradec(
		math.rad((23 + (11 + 57 / 60) / 60) * 360 / 24),
		math.rad(7.22))

	local radecFrame = celestia:newframe("universal", std.radecRotation)
	observer:lookat(
		observer:getposition() - toCold, --Look away from the cold spot,
		radecFrame:from(std.yaxis))      --with Polaris towards top.

	--Indigo is weak.
	local color =
		{"red", "orange", "yellow", "green", "blue", "indigo", "violet"}

	local seconds = 30	--of real time; may have to make it even longer
	local s = string.format("Painting the sky will take about %d seconds.",
		seconds)
	celestia:print(s, 5)
	wait(5)
	celestia:settimeslice(seconds)
	local t0 = celestia:getscripttime()

	for star in celestia:stars() do
		local toStar = star:getposition():tovector()

		if toStar:length() > 0 then	--don't let angle divide by zero
			local theta = toCold:angle(toStar) --from star to Aquari

			--Convert theta to int in range 1 to #color inclusive.
			local c = math.ceil(#color * theta / math.pi)
			star:mark(color[c], "diamond", 10, .25)	--size and alpha
		end
	end

	--Don't drag on sky until it is completely painted.
	s = string.format("Painting the sky took %.15g seconds.",
		celestia:getscripttime() - t0)
	celestia:print(s, 60)
	wait()
end

main()

Experiment with a more impressionistic way of painting the sky.

--misty, overlapping disks star:mark(color[c], "disk", 100, .008)

Vectors to the Same Star at Two Times

The annual parallaxparallax of a star is the change in its apparent position, as seen from the Earth, caused by the Earth’s revolution around the Sun. Let’s measure the annual parallax of the nearest star, Proxima CentauriProxima Centauri (star). The Proxima system is easy to work with because Celestia considers it to be a single star, disregarding its tenuous connection with Alpha CentauriAlpha Centauri (star).

As in direction, the next-to-last argument of Observer:lookatObserver:lookat is the target position. The last argument is the up vector. The rarely seen third-from-last argument of Observer:lookat is the source position. If the source position is absent, the observer looks directly at the target position. If the source position is present, he looks along a vector parallel to the one from the source position to the target position. When the following lookats are executed, six months apart, the observer will therefore look along parallel vectors. This will keep the background sky immobile as Proxima blinks back and forth.

Proxima blinks along a line parallel to the ecliptic. We therefore display the ecliptic grid instead of the equatorial, and our up vector is std.yaxis. It points towards the north pole of the ecliptic in Draco, making the ecliptic horizontal.

We make the Earth invisible so that the observer, at its center, can see out of it. The toProxima vectors go from the center of the Earth to Proxima. As usual, the vectors are in microlightyears. Celestia knows most stellar distances to only at most 10 significant digits (see version/data/stars.txt), so the distance read from the database is formatted with "%.10g".

--[[
Blink Proxima Centauri back and forth due to is annual parallax.
Watch the date change in the upper right corner.
]]
require("std")

local function main()
	local observer = celestia:sane()
	--Minimum field of view, for maximum magnification.
	observer:setfov(.001)	--.001 radians = approx. 26 seconds of arc.

	--Set up for astrometry.  With automagautomag (renderflag) off,
	--setfaintestvisible can go all the way down to the 15th magnitude.
	celestia:hide("automag", "galaxies", "grid")
	celestia:show("eclipticgrid")
	celestia:setfaintestvisibleCelestia:setfaintestvisible(15)
	celestia:setstarstyleCelestia:setstarstyle("point")	--default is "fuzzy"
	celestia:settimescale(0)	--freeze the passage of time

	local earth = celestia:find("Sol/Earth")
	local proxima = celestia:find("Proxima Centauri")

	--Make sure we can see the universe from the center of the Earth.
	earth:setvisibleObject:setvisible(false)

	--a is an array with two values, a[1] and a[2].  Each value will be
	--a table with four fields: time, earth, proxima, toProxima.
	local year = celestia:tdbtoutc(celestia:gettime()).year --current year
	local a = {
		{time = celestia:utctotdb(year,  2, 17)},
		{time = celestia:utctotdb(year,  8, 22)}
	}

	for i = 1, #a do
		a[i].earth = earth:getposition(a[i].time)
		a[i].proxima = proxima:getposition(a[i].time)
		a[i].toProxima = a[i].proxima - a[i].earth
	end

	local theta = a[1].toProxima:angle(a[2].toProxima)
	local radians = theta / 2                   --parallax in radians
	local seconds = 60 * 60 * math.deg(radians) --parallax in seconds of arc
	local baseline = (a[2].earth - a[1].earth):length() / 2   --orbit radius
	local parallaxDistance = baseline / math.sin(radians)

	local measuredDistance =
		(a[1].toProxima:length() + a[2].toProxima:length()) / 2

	local s = string.format(
		"Parallax %.15g%s yields a distance of %.15g parsecs\n"
		.. "or %.15g lightyears.\n"
		.. "Distance read from database is %.10g lightyears.",
		seconds, std.char.second,
		1 / seconds,		--convert parallax to parsecs
		parallaxDistance / 1e6,	--convert microlightyears to lightyears
		measuredDistance / 1e6)

	celestia:print(s, 60)

	while true do		--infinite loop
		for i = 1, #a do
			celestia:settime(a[i].time)
			observer:setposition(a[i].earth)
			observer:lookat(std.position0, a[i].proxima, std.yaxis)
			wait(1)	--Freeze the picture for one second.
		end
	end
end

main()
Parallax 0.768987719149221″ yields a distance of 1.30041088446297 parsecs or 4.24218988334848 lightyears. Distance read from database is 4.24200022 lightyears.

Remove the third-from-last argument of Observer:lookat. Note that Proxima now remains immobile, while the background sky (i.e., the ecliptic grid) blinks back and forth.

How did we pick the dates when Proxima would be the farthest to the left and right? Construct a vector from the Sun to the Earth, and a vector from the Sun to Proxima Centauri. Display the angle in degrees between the vectors. By trial and error, find the two dates when the angle is 90°. We’ll be able to do this automatically in findZero.

Celestia knows about the orbits of individual stars within a star system, but not about the motion of the system through the galaxy. We therefore made no attempt to measure the secular parallaxsecular parallax of Proxima, caused by the motion of the Sun and Proxima through the galaxy over the course of many years.

The greatest secular parallax is exhibited by Barnard’s StarBarnard’s Star. Demonstrate conclusively that Celestia thinks the stars are fixed by displaying two views of this star, ten years apart.

Simulate Clyde TombaughTombaugh, Clyde’s famous pair of discovery photos of PlutoPluto, discovery of, January 23 and 29, 1930. Blink back and forth between them.

Does the Sun have planets? Could an extraterrestrial astronomer at a nearby star see the Sun’s wobble around the Solar System Barycenter? Could he, she, it, or they deduce the existence of JupiterJupiter, effect on Sun of?

Cross Productcross product and Perpendicular Vectors

The cross product of two vectors v and w is a vector perpendicular to both of them. The cross product is written as v ^^ (cross product operator) w in Celx and as v × w in mathematics. (The cross is std.char.crossstd.char.cross, and the caretcaret (cross product operator) is half of a cross.)

The unit vectors right and up in the following program point right and up. Their cross product towardsUs is a unit vector pointing towards us. It is perpendicular to right and up because it is the result of a cross product operation. It points towards us rather than away from us because of the following right-hand ruleright-hand rule for cross product.

Make a “thumbs up” fist with your right hand and grasp towardsUs in your four non-thumb fingers, keeping the following requirements satisfied.

  1. Your thumb must point away from the origin.
  2. Your four non-thumb fingers must curl from the left operand of the cross product operator (the vector right) towards the right operand (the vector up).

Given our three vectors, there’s only one way to satisfy both requirements. Your fist will be in front of you, knuckles up, with your thumb aimed at your eye. The right-hand rule says that your thumb is now pointing in the direction of the cross product. The cross product operation is not commutativecommutativity, cross product lacking in: if we swap the two operands, the resulting vector will have its direction reversed. The cross product is not associativeassociativity, cross product lacking in either.

The length of the cross product is ||v × w|| = ||v|| ||w|| sin θ where θ is the angle between v and w. The length is zero if the vectors are colinearcolinear vectors, pointing in the same or in opposite directions, because sin 0° = sin 180° = 0. The length is also zero if either vector is of length zero.

The length of the cross product can be visualized as the area of the parallelogram spanned by v and w. It is zero when θ = 0° or 180°, and greatest when θ = 90° and the parallelogram is a rectangle. The parallelogram in the following program is a unit square, so the cross product is of length 1.

Do not try to compute the angle between the vectors by solving the above formula for θ. The angles of 45° and 135°, for example, have the same sine (1/2), and the cross product gives no clue as to which of these angles is θ. To get the angle, use the dot product in dotProduct.

--[[
Compute the cross product of two perpendicular unit vectors.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	local right = celestia:newvector(1, 0, 0)
	local up    = celestia:newvector(0, 1, 0)
	local towardsUs = right ^ up	--cross product is (0, 0, 1)

	local s = string.format(
		"(%.15g, %.15g, %.15g) %s "
		.. "(%.15g, %.15g, %.15g) = "
		.. "(%.15g, %.15g, %.15g)\n"
		.. "%s = %.15g%s\n",

		right:getx(), right:gety(), right:getz(),
		std.char.cross,
		up:getx(), up:gety(), up:getz(),
		towardsUs:getx(), towardsUs:gety(), towardsUs:getz(),
		std.char.theta, math.deg(right:angle(up)), std.char.degree)

	celestia:print(s, 60)
	wait()
end

main()
(1, 0, 0) × (0, 1, 0) = (0, 0, 1) θ = 90°

The cross product will be used to find the line common to two intersecting planes. Elegant examples are in Sundial, which draws the lines on the face of a sundial, and recoverElliptical, which constructs a vector pointing towards the “ascending node” of an orbit.

Make Orion’s BeltOrion’s Belt Horizontal

We made Cygnus vertical in direction. Now let’s make Orion’s Belt horizontal. From left to right its three stars are AlnitakAlnitak (star) ("Zeta Orionis"), AlnilamAlnilam (star) ("Epsilon"), and MintakaMintaka (star) ("Delta").

The vector mintaka:getposition() - p points from the observer to Mintaka, and the vector alnitak:getposition() - p from the observer to Alnitak. Their cross product, up, is a vector perpendicular to the first two. It points north rather than south because of the above right-hand rule. With up pointing up, the belt is horizontal.

--[[
Look at Orion's Belt.
Orient the observer so that the belt is horizontal, with north at the top.
]]
require("std")

local function main()
	local observer = celestia:sane()
	observer:setfov(math.rad(5))
	celestia:show("boundaries")
	celestia:setconstellationcolor(0, 2 * .24, 2 * .36, {"Orion"})

	local p = std.position	--Avoid Sol's glare.
	observer:setposition(p)

	--The stars of the belt, left to right:
	local alnitak = celestia:find("Alnitak")	--Zeta Orionis
	local alnilam = celestia:find("Alnilam")	--Epsilon
	local mintaka = celestia:find("Mintaka")	--Delta

	local up = (mintaka:getposition() - p) ^ (alnitak:getposition() - p)
	observer:lookat(alnilam:getposition(), up)
	wait()
end

main()

After launching the above program, zoom out by pressing .. (zoom out command) (period) to verify that the belt points left to Sirius, right to Aldebaran. Then automate the zoom by adding the following code. Hide the grid while the zoom is in progress because it distracts the user when the grid lines disappear as we pull back. As we zoom out, the fraction (t - t0) / (t1 - t0) goes from 0 to 1. The variable fov therefore goes from fov0 to fov1.

--variables used by tickHandler: local observer = celestia:sane() --Five-second zoom out, --starting three seconds after the "CELESTIA" logo disappears. local t0 = std.logo + 3 local t1 = t0 + 5 --Horizontal field of view in degrees at start and end of zoom. local fov0 = 5 local fov1 = 75 --wide enough to include the Pleiades on the right local function tickHandler() local t = celestia:getscripttime() if t < t1 then local fov = fov0 + (fov1 - fov0) * (t - t0) / (t1 - t0) observer:sethorizontalfov(math.rad(fov)) else celestia:registereventhandler("tick", nil) celestia:show("grid") end end --At the end of main, immediately after lookat. --(observer is no longer created in main.) observer:sethorizontalfov(math.rad(fov0)) wait(t0) celestia:hide("grid") celestia:registereventhandler("tick", tickHandler) wait()

After you read gradualStart, change the local fov = fov0 + (fov1 - fov0) * (t - t0) / (t1 - t0) in the above fragment to local fov = std.spline(t, t0, t1, fov0, fov1)

How straight is Orion’s belt? Consider the plane containing Mintaka, the Sun, and Alnilam, and use a cross product to construct a vector perpendicular to this plane. Do the same for the plane containing Alnilam, the Sun, and Alnitak. The angle between the vectors should be about 7.5°, so the belt is 7.5° from having a straight angle of 180° at Alnilam.

How equiangular is the Winter TriangleWinter Triangle of SiriusSirius (star), ProcyonProcyon (star), and BetelgeuseBetelgeuse (star)? Is the sum of its angles greater than 180°?

Keep the Crescent of VenusVenus, phases of Upright

CynthiæCynthia (Moon goddess) figuras æmulatur mater amorum.
The Mother of Love emulates Cynthia’s shapes.
[Venus imitates the phases of the Moon.]
GalileoGalileo, letter to the Tuscan ambassador in Prague

Let’s watch Venus going through her phases as seen from the Earth. But first we will have to introduce some terminology. The limblimb of a object seen from a distance is the edge of the object. For example, the limb of a spherical object is a circle. The terminatorterminator of a non-luminous body is the line along its surface that separates day and night. For example, the terminator of a spherical object is a circle. The hornshorns (of crescent) of a crescentcrescent (phase) are the two points where the terminator crosses the limb of the body as seen by an observer. The horns are sharpest during the crescent phase, but continue to exist in every phase except full and new.

The following program keeps Venus’s horns in a vertical line. The vector toSol points from the Earth to the Sun, and toVenus points from the Earth to Venus. Their cross product up is perpendicular to both vectors. It is parallel to the line segment connecting the horns as seen from Earth. We will keep this vector vertical.

Two things can go wrong when we compute up. If Venus is directly in front of or behind the Sun, toSol and toVenus will be colinearcolinear vectors and their cross product will be the zero vector. In this case, we reuse the value of up from the previous iteration of the loop.

The other problem is that although up is vertical, it might be pointing up (north towards Draco) or down (south towards Dorado). If up turns out to be more than 90° away from Draco, we negate it to keep it pointing north.

The time interval dt between frames, about four Earth days, keeps Venus’s cloudsVenus, clouds of in the same position in each snapshot. See solarDays for its derivation.

--[[
Show the phases of Venus as seen from the Earth.
Make it look like the clouds are stationary.
]]
require("std")

local function main()
	local observer = celestia:sane()
	celestia:hide("constellations", "ecliptic", "galaxies", "grid",
		"markers")
	celestia:hidelabel("constellations", "planets")
	celestia:setambientCelestia:setambient(.05)   --make night side of Venus barely visible

	--telescopic view of Venus from Earth, 1.25 minutes of arc
	observer:setfov(math.rad(1.25 / 60))

	local sol = celestia:find("Sol")
	local venus = celestia:find("Sol/Venus")
	local earth = celestia:find("Sol/Earth")
	celestia:select(venus)

	--Make sure we can see the universe from the center of the Earth.
	earth:setvisible(false)

	--c is the number of Earth days it takes for the clouds to circle Venus
	--with respect to the surface of Venus.
	local speed = venus:getinfo().atmosphereCloudSpeed  --in radians per day
	assert (speed ~= 0, "can't divide by atmosphereCloudSpeed of 0")
	local c = 2 * math.pi / speed

	--dt is the number of Earth days it takes for the clouds to circle Venus
	--with respect to the universal frame.
	local r = venus:getinfo().rotationPeriod
	local dt = r * c / (r + c)

	celestia:settimescale(0)	--Freeze the passage of time.
	local lastUp = std.yaxis

	while true do
		celestia:settime(celestia:gettime() + dt)  --Go dt days forward.
		local earthPosition = earth:getposition()
		local venusPosition = venus:getposition()
		local toSol = sol:getposition() - earthPosition
		local toVenus = venusPosition - earthPosition
		local up = toSol ^ toVenus

		if up:length() == 0 then
			up = lastUp
		elseif up:angle(std.yaxis) > math.rad(90) then
			up = -- (vector unary minus operator)up	--Point in the opposite direction.
		end

		observer:setposition(earthPosition)
		observer:lookat(venusPosition, up)
		lastUp = up
		wait(1 / 4)		--4 frames per second of real time
	end
end

main()

We kept the horns in a vertical line, at the price of rolling the planet around. Now keep Venus’s north and south poles in a vertical line, at the price of rolling the horns around. Change the last argument of Observer:lookatObserver:lookat to std.yaxis.

No one would have believed in the last years of the nineteenth century that this world was being watched keenly and closely by intelligences greater than man’s and yet as mortal as his own …
H. G. WellsWells, H. G., The War of the WorldsWar of the Worlds, The (Wells) (1898)

Display the phases of Earth as seen from Mars. Since the Earth’s continents are more conspicuous than its clouds, let dt be earth:getinfo().rotationPeriod. Better yet, speed up the movie by letting dt be 8 * earth:getinfo().rotationPeriod.

See how the Celx standard library makes it possible to apply the unary minus operator to a vector object. Read the __unm__unm (in metatable) method (two underscores) in the metatablemetatable (Lua) for class Vector in std.lua (stdCelx).

Use the “lock frame” in lockFrame to keep the observer near the Earth, looking at the Sun with the normal field of view. Then call Object:setradius to make Venus big enough to see its phases from the Earth.

Erect a Vectorerect a vector

Any two vectors define a plane, provided they are nonzero and non-colinear. For example, the following vectors forward and up lie in the YZ plane. They are named after the most important application of the maneuver we’re about to see.

Despite their names, the two vectors are not perpendicular: the angle between them is only 45°. To rectify the situation we will rotate up until it becomes perpendicular to forward, taking care to keep it in the YZ plane. There are actually two vectors in the YZ plane that are perpendicular to forward; our new up will be the one that is less than 90° from the original up.

We first construct the vector normal perpendicular to the plane of forward and up. (Normalnormal (perpendicular) vectors in this sense means “perpendicular”.) We then construct the cross product normal ^ forward, which lies in the plane because it is perpendicular to normal. (As the proverb has it, the enemy of my enemy is my friend.) This cross product is also perpendicular to forward. We normalize it to simplify the output because we are interested only in its direction, not its length. (Normalnormalize a vector in this sense means “of length 1”.)

Given any original values of forward and up, the angle between the original up and the new up will always less than 90°. Changing normal ^ forward to forward ^ normal would make the angle greater than 90°. In either case, the new up would still be perpendicular to forward.

--[[
Given a pair of vectors, forward and up, keep up in the plane common to forward
and the original up, but make up perpendicular to forward.  Rotate up by less
than 90 degrees.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	local forward = celestia:newvector(0, 0, -1)	--points away from us
	local up      = celestia:newvector(0, 1, -1)	--up and away

	local normal = forward ^ up

	--Assert that forward and up are nonzero and non-colinear.
	assert(normal:length() > 0, "forward and up do not define a plane")

	up = (normal ^ forward):normalize() --up now perpendicular to forward.

	local s = string.format("(%.15g, %.15g, %.15g)", up:getxyz())
	celestia:print(s, 10)
	wait()
end

main()

Fortunately, negative zero is equal to positive zero.

(-0, 1, 0)

An easier way to do the same thing is is

local forward = celestia:newvector(0, 0, -1) --points away from us local up = celestia:newvector(0, 1, -1) --up and away up = forward:erectVector:erect(up) --up now perpendicular to forward, length = 1

See how Vector:erect has already been used to keep the observer on the Earth’s equator in celestialSphere, and to keep his forward vector from straying off the solstitial colure in colures.

Orientations, Rotations, and Quaternions

An observer’s orientationorientation gives the direction towards which he is looking and the direction towards his zenith. An orientation can therefore be represented by a forward vectorforward vector of orientation and an up vectorup vector of orientation. These vectors should ideally be perpendicular. If they are not, the forward vector takes precedence and the up vector is adjusted (“erected”) to accommodate it as we just saw in erect. For conversion between an orientation and the forward/up vectors that represent it, see forwardUp.

The observer’s initial orientationorientation, default is enshrined in the standard library as std.orientation0std.orientation0. Its forward vector points along the negative Z axis of the universal frame, towards the summer solstice at RA 6h Dec +23° in Taurus/GeminiTaurus/Gemini. Its up vector points along the positive Y axis, towards the north pole of the ecliptic at RA 18h Dec +67° in DracoDraco. These two points lie along the solstitial coluresolstitial colure in colures.

Every orientation determines a pair of forward and up vectors. But not every pair of vectors represents an orientation. To represent an orientation, the forward and up vectors must be nonzero and non-colinearcolinear vectors, i.e., they must not lie in the same line. To test for these requirements, we can verify that the cross product of the two vectors is nonzero as we just did in erect.

Another way to represent an orientation is by the rotationrotation needed to get there from the initial orientation. And a rotation can be represented by a quaternionquaternion consisting of a real partreal part of quaternion and an imaginary partimaginary part of quaternion. The real part is a number giving the angle of the rotation, and the imaginary part is a vector giving the axis of the rotation. The value of the real part is the cosinecosine (trig function) of half of the angle of the rotation. This value ranges from 1 to –1 inclusive and represents a rotation of to 360° inclusive. For example, a real value of 1/2 is the cosine of 45° and represents a rotation of 90°.

The length of the imaginary part is the sinesine (trig function) of half the angle of rotation. For example, the vector (1/2, 0, 0) has a length of 1/2, which is the sine of 45° and could represent a rotation of 90°. But we will never get the angle of rotation from the length of the imaginary part: that 1/2 is also the sine of 135°, and could represent a rotation of 270°. We will always get the angle from the real part.

The axis of the rotation always goes through the position of the observer. This means that the rotation can yaw, pitch, or roll him, but it cannot change his position. For example, our imaginary part of (1/2, 0, 0) does not represent a vector that starts at the Solar System Barycenter at (0, 0, 0) and goes to the point (1/2, 0, 0). It represents an axis that is parallel to this vector and that goes through his position.

Like a position and a vector, a rotation object has numeric fields. There are four of them, hence the name quaternion. The real part is w and the imaginary part comprises x, y, and z. The method Rotation:realRotation:real returns the number w, and Rotation:imagRotation:imag returns the vector (x, y, z). A rotation does not have the methods getx, gety, getz, and getxyz.

A unit quaternionunit quaternion is one whose w2 + x2 + y2 + z2 = 1 It’s easy to prove that a quaternion representing a rotation is a unit quaternion. Give the angle of rotation θ, we have already said that w = cos θ 2 and x2 + y2 + z2 = ||v|| = sin θ 2 Therefore w2 + x2 + y2 + z2 = cos2 θ 2 + sin2 θ 2 = 1

Our description of a rotation must also include its direction. Let’s consider an observer rotating through an angle of 90° around the axis (1/2, 0, 0). To visualize the direction of the rotation, grasp the axis in your right hand with your thumb pointing away from the origin. (In this example, your thumb will be pointing to the right.) The direction in which the observer rotates will be the opposite of the direction in which your four non-thumb fingers point around the axis. In our case, the observer’s forward vector will pitch down. If we negate the angle to –90°, or negate the axis to (–1/2, 0, 0), the forward vector will pitch up.

The simplest orientation is the observer’s initial orientation, std.rotation0std.rotation0. Its real part is 1, which is the cosine of and represents a rotation of 0°. Its imaginary part is the vector (0, 0, 0), whose length is the sine of and represents a rotation of 0°.

An object that represents the orientation of an observer will be called an orientation (getOrientation and setOrientation). An object that represents a change in orientation, instantaneous or animated, will be called a rotation (rotateObserver). And when we want to emphasize that an orientation or rotation contains four numbers, or when we multiply orientations or rotations together, it will be called a quaternion (transformVector). But all three of these data types are the same classRotation (class) of object in Celx, and the tostringtostring (Lua function) function renders all three of them as "[Rotation]". For example,

local orientation = observer:getorientation() assert(type(orientation) == "userdata" and tostring(orientation) == "[Rotation]") local axis = celestia:newvector(1, 0, 0) local angle = math.rad(90) local rotation = celestia:newrotation(axis, angle) assert(type(rotation) == "userdata" and tostring(rotation) == "[Rotation]") local quaternion = std.radecRotation local v = celestia:newvector(0, 0, -1) local w = quaternion:transform(v) assert(type(quaternion) == "userdata" and tostring(quaternion) == "[Rotation]") --[[ v is a vector in J2000 equatorial coordinates pointing towards RA 0 Dec 0 in Orion. w is a vector in universal coordinates pointing towards the same place. w = (0, sin(-23 degrees), cos(-23 degrees)). ]]

The axis of a orientation or quaternion is always written with respect to the universal frame. The axis of a rotation is written in the frame of the object being rotated. For example, the rotation in randolph rotates a position written with respect to the Earth’s “ecliptic” frame of reference. The imaginary part of the rotation is therefore assumed to be a vector in that frame.

An orientation cannot be named oror (Lua keyword), which is a Lua keywordkeyword (Lua).

Get the Observer’s Orientation

The method Observer:getorientationObserver:getorientation returns the observer’s orientation as a rotation whose imaginary part (the axis) is always in universal coördinates. We’ll display the angle of the rotation in degrees. If the angle is or 360°, the axis is of length zero and is irrelevant.

std.forwardstd.forward and std.upstd.up are the forward and up vectors of the initial orientation std.orientation0std.orientation0. The tick handler transformsRotation:transform (transformVector) them into the forward and up vectors of the observer’s current orientation. The function vectorFormat then converts the vector coördinates from the universal frame (X axis pointing towards Pisces, Y towards Draco) toFrame:to the radecFrame (X axis pointing towards Pisces, Y towards Polaris).

A celestial Object has no getorientation method because we can get its orientation from the axes of its “bodyfixed” frame (bodyfixedFrame). See also Phase:getorientation.

--[[
Display the observer's orientation, initially std.orientation0.
To change his orientation, drag on the sky or press the arrow keys.
]]
require("std")

--variable used by tickHandler:
local observer = celestia:sane()
local radecFrame = celestia:newframe("universal", std.radecRotation)

local function vectorFormat(v)
	assert(type(v) == "userdata" and tostring(v) == "[Vector]")

	--Convert coordinates from universal frame to radecFrame.
	--Then convert Cartesian coordinates to spherical.
	local longlat = radecFrame:to(v):getlonglat()

	--Break down radians into conventional units.
	local ra = std.tora(longlat.longitude)
	local dec = std.todec(longlat.latitude)

	return string.format(
		"(%g, %g, %g)\n"
		.. "points towards RA %dh %dm %gs "
		.. "Dec %s%d%s %d%s %g%s",
		v:getx(), v:gety(), v:getz(),
		ra.hours, ra.minutes, ra.seconds,
		std.sign(dec.signum),
		dec.degrees, std.char.degree,
		dec.minutes, std.char.minute,
		dec.seconds, std.char.second)
end

local function tickHandler()
	local orientation = observer:getorientation()
	assert(type(orientation) == "userdata"
		and tostring(orientation) == "[Rotation]")

	local real = orientation:real()
	assert(type(real) == "number")

	local s = string.format("The angle of rotation is %g%s.\n\n",
		math.deg(2 * math.acosmath.acos(real)), std.char.degree)

	if math.absmath.abs(real) ~= 1 then	--angle of rotation is not 0 or 360
		local axis = orientation:imag()
		assert(type(axis) == "userdata" and
			tostring(axis) == "[Vector]")

		s = s .. string.format("The axis of rotation %s.\n\n",
			vectorFormat(axis))
	end

	s = s .. string.format(
		"The forward vector %s.\n\n"
		.. "The up vector %s.",
		vectorFormat(orientation:transform(std.forward)),
		vectorFormat(orientation:transform(std.up)))

	--The observer's forward vector always points towards the center of the
	--window.  Mark the center with MM.
	celestia:mmprint(s)
end

local function main()
	--Keep the Sun in view, but get away from its glare.
	observer:setposition(std.position)

	celestia:registereventhandler("tick", tickHandler)
	wait()
end

main()

The first diagram and box of output show the initial orientation aimed at Taurus/GeminiTaurus/Gemini. The forward and up vectors point along the negative Z and positive Y axes respectively.

MM The angle of rotation is 0°. The forward vector (-0, -0, -1) points towards RA 6h 0m 0s Dec +23° 26′ 21.448″. The up vector (0, 1, 0) points towards RA 18h 0m 0s Dec +66° 33′ 38.552″.

The second diagram and output result from pitching the user 23° down to Orion. This angle is the tilt of the Earth’s axis with respect to the ecliptic, known in radians as std.tiltstd.tilt. The –0.397777 and 0.917482 are the sine and cosine of –23°. The 0.203123 is the sine of half of 23°.

MM The angle of rotation is 23.4393°. The axis of rotation (0.203123, 0, 0) points towards RA 0h 0m -0s Dec 0° 0′ 0″. The forward vector (-0, -0.397777, -0.917482) points towards RA 6h 0m 0s Dec 0° 0′ 0″. The up vector (0, 0.917482, -0.397777) points towards RA 0h 0m 0s Dec +90° 0′ 0″.

An orientation is supposed to be a unit quaternion. How close is the following expression to 1?

orientation:real()^2 + orientation:imag():length()^2

Set the Observer’s Orientation

Tired of gazing into the summer solstice in Taurus/GeminiTaurus/Gemini? Let’s pitch the observer’s direction of view down through an angle of 23° along the solstitial colure. We will leave him pointing at RA 6h Dec on the celestial equator, directly below the solstice and to the left of Orion’s liver.

The method Celestia:newrotationCelestia:newrotation creates and returns a new orientation. But the two arguments of this method are very different from the return values we got from Rotation:realRotation:real and Rotation:imagRotation:imag. The first argument of newrotation is a unit vector in universal coördinates, while the return value of imag was a unit vector times the sine of half the angle of rotation. The second argument of newrotation is the angle of rotation in radians, while the return value of real was the cosine of half the angle.

The following orientation orion differs from the initial orientation by a 23° rotation around the X axis, pitching the observer’s direction of view down. Equivalently, we could say that the universe pitched upwards. Had we sneaked a peek to our right, away from the observer along the positive X axis, we would have seen the universe turn clockwise.

--[[
Point the observer at Orion, with Polaris overhead. In other words,
point his forward vector at Orion, and his up vector at Polaris.
]]
require("std")

local function main()
	local observer = celestia:sane()
	observer:setposition(std.position)	--Get away from the Sun's glare.

	local axis = celestia:newvector(1, 0, 0)	--axis of rotation
	local theta = std.tilt				--angle of rotation

	--Think of orion as the orientation produced by the rotation.
	local orion = celestia:newrotation(axis, theta)
	assert(type(orion) == "userdata" and tostring(orion) == "[Rotation]")

	observer:setorientationObserver:setorientation(orion)
	celestia:mmprint("", 60)
	wait()
end

main()

Write std.xaxisstd.xaxis in place of celestia:newvector(1, 0, 0). It has the same value.

Create the same orientation by specifying its forwardforward vector of orientation and upup vector of orientation vectors. See forwardUp.

local toOrion = celestia:newvectorradec(math.rad(360 * 6 / 24), math.rad(0)) local toPolaris = celestia:newvectorradec(math.rad(0), math.rad(90)) local orion = celestia:newrotationforwardup(toOrion, toPolaris)

A spherical linear interpolationspherical linear interpolation (slerpslerp (spherical linear interpolation)) produces the average of two orientations. Verify that the following call to Rotation:slerpRotation:slerp leaves us dangling to the upper left of Orion’s raised elbow.

local taurusGemini = std.orientation0 local orion = celestia:newrotation(std.xaxis, std.tilt) --elbow is halfway between the above orientations. local elbow = orion:slerp(taurusGemini, .5) assert(type(elbow) == "userdata" and tostring(elbow) == "[Rotation]")

Is orion:slerp(taurusGemini, .5) the same as taurusGemini:slerp(orion, .5)? Try values from 0 to 1 inclusive as the second argument of slerp.

Rotate the Observer

“Location, he used to say,” said the Navigator. “Universal location at every minute, every second. All you have to have is the key.”

“What key?” Cahill asked.

“A key like spherical trig, for one thing. If you can work it, and sight on some things up there in God’s heaven, the universe will tell you where you are, and will not fail to tell you. It can’t. It’s locked around your key. All you have to do is turn it. It’s complicated, but it works.… Mathematics, and especially astronomy, he called precision mysticism.

James DickeyDickey, James (who minored in astronomy), AlnilamAlnilam (novel by James Dickey) (1987)

The method Observer:setorientationObserver:setorientation in setOrientation ignored and obliterated the observer’s existing orientation. A more incremental approach would be to produce his new orientation by applying a rotation to the existing one. The following program does this, leaving him in the same orientation as the previous program. The name of the object has been changed from orion to down to encourage us to think of it as a rotation downwards rather than an orientation. The punchline has been changed from Observer:setorientation to Observer:rotateObserver:rotate.

The coördinates of the axis (imaginary part) of the orientation passed to Observer:setorientation are measured with respect to the universal frame. But the coördinates of the axis of the rotation passed to Observer:rotate are measured with respect to the observer’s personal frameobserver’s personal frame. In this frame, the origin is the observer’s current position. The X axis points towards his current right, the Y axis towards his current zenith, and the Z axis towards his current rear. In his initial orientation, the axes of his personal frame are therefore parallel to those of the universal frame, and a rotate will have the same effect as a setorientation with the same argument. But the observer’s personal frame changes with his orientation. The next rotate will not have the same effect as a setorientation.

Our rotation takes place instantly. To animate it over time, see animateRotation. For future use, we also show the conjugate of a rotationconjugate of rotation. Officially, the conjugate has the same angle (real part) as the original rotation, but its axis points in the opposite direction. Unofficially, the conjugate has the same axis as the original rotation, but the opposite angle. For example, the conjugate of down is up. The unofficial definition is simpler to visualize, but the official definition keeps angle of rotation nonnegative.

--[[
Rotate the observer 23 degrees down from his previous orientation.
Leave him pointing towards Orion.
]]
require("std")

local function main()
	local observer = celestia:sane()
	observer:setposition(std.position)	--Get away from the Sun's glare.

	--Four rotations.
	local down = celestia:newrotation(std.xaxis, std.tilt)
	local right = celestia:newrotation(std.yaxis, math.rad(90))
	local up = down:conjugateRotation:conjugate()	--could also say local up = down ^^ (conjugate operator) -1
	local left = right:conjugate()
	assert(type(up) == "userdata" and tostring(up) == "[Rotation]")

	observer:rotate(down)	--instantaneous, not animated
	celestia:mmprint("", 60)
	wait()
end

main()

Pitch the observer a total of 46° down from the initial orientation. Leave him pointing below Orion’s feet, at his pet rabbit LepusLepus the Hare.

observer:rotate(down) observer:rotate(down) --cumulative effect --The observer is now aimed at Lepus the Hare. --Polaris is no longer overhead; it's now above and behind him.

Note that the following code will not take us down to Lepus. The value 2 ** (scalar times quaternion operator) down is not a unit quaternion at all (orientation); it is merely down with the w, x, y, z coördinates doubled. observer:rotate(2 * down) The desired effect may be obtained by the “quaternion multiplication” in quaternionMultiplication. observer:rotate(down * down) --two applications of down --The observer is now aimed at Lepus the Hare.

Change the rotation to the following. observer:rotate(right) This yaws the observer 90° to the right. It slides his target of view (his forward vector) along the red ecliptic to the vernal equinox in Pisces. He rotates around his Y axis, which is currently the Y axis of the universal frame and the axis of the ecliptic. The ecliptic remains horizontal during the rotation, proving that the north pole of the ecliptic in Draco remains directly overhead (at his up vector). But check it anyway. Turn on Celestia:show("eclipticgrid") and pan upwards with the down arrow. You should find the crook of Draco at the observer’s zenith.

Change the rotation to the following. observer:rotate(down) observer:rotate(right) Observe that we end up with the same forward vector as in the previous exercise, pointing towards the vernal equinox in Pisces. But our up vector is different. This time it is the celestial equator that is horizontal, proving that Polaris is overhead.

The down rotation pitched the observer down, rotating him around his X axis which points to his right. His Y axis now points towards his new zenith, at the north celestial pole near Polaris. Then the right rotation yawed him to the right around his Y axis, sliding his point of view along the celestial equator from Orion to Pisces. His zenith remained at Polaris during the slide.

Change the rotation to the following. observer:rotate(right) observer:rotate(down) We are now deposited at CetusCetus the Whale—uncharted territory along the equinoctial colure, 23° below the vernal equinox in Pisces. The whale looks sort of like a big Big DipperBig Dipper. How did we get here? First the right rotation yawed the observer’s point of view to Pisces, with Draco remaining overhead. The observer’s X axis is now parallel to the universal Z axis. It points towards SagittariusSagittarius, a constellation that is usually directly behind us. Then the down rotation pitched him down around his X axis. Draco is no longer overhead; it is now above and behind the observer’s zenith.

We can still get to Draco very easily from our new orientation. Show the "eclipticgrid" and observe that the line of ecliptic longitude through the center of the window is vertical. Then press the down arrow key to pan upwards along this line until we reach the crook of Draco. Since our starting point in Cetus is below the ecliptic, we will have to pan up more than 90°.

We have just discovered that changing the order of the rotations can change the resulting orientation. Our mathematician friend would say that a series of rotations is not commutativecommutativity, rotation multiplication lacking in.

Quaternion Multiplicationquaternion multiplication

By diverse means we arrive at the same end.
Michel de MontaigneMontaigne, Michel de, EssaysEssays (Montaigne), Book 1 (1580)

The down and up rotations in the following program would pitch the observer 23° down and 23° up, leaving his orientation unchanged. With quaternion multiplication we can combine them into a single rotation with the same effect, namely, no effect at all.

--[[
Multiply a pair of quaternions.
The product leaves the observer in the initial orientation.
]]
require("std")

local function main()
	local observer = celestia:sane()
	--Keep the Sun in view, but get away from its glare.
	observer:setposition(std.position)

	local down = celestia:newrotation(std.xaxis, std.tilt)
	local up = down:conjugateRotation:conjugate()

	local rotation = up ** (quaternion times quaternion operator) down	--quaternion multiplication
	assert(type(rotation) == "userdata" and
		tostring(rotation) == "[Rotation]")

	observer:rotate(rotation)
	celestia:mmprint("", 60)
	wait()
end

main()

The product up * down just happens to be equal to down * up. But we usually won’t be so lucky: quaternion multiplication is not commutative. This should come as no surprise, because the series of rotates we just saw in rotateObserver was not commutative. In fact, the following program demonstrates the non-commutativity of quaternion multiplication with our old friends down and right.

--[[
Multiply a pair of quaternions in one order.
You can edit the program and run it again to try them in the opposite order.
]]
require("std")

local function main()
	local observer = celestia:sane()
	--Keep the Sun in view, but get away from its glare.
	observer:setposition(std.position)

	local down  = celestia:newrotation(std.xaxis, std.tilt)
	local right = celestia:newrotation(std.yaxis, math.rad(90))
	observer:setorientation(right * down)

	celestia:mmprint("", 60)
	wait()
end

main()

The above Observer:setorientation has the same result as the following series. observer:rotate(down) observer:rotate(right) It leaves us at the vernal equinox in Pisces with the north celestial polenorth celestial pole overhead. (Note that the celestial equator is horizontal.) Incidentally, we would have gotten the same result from the following, since we are starting from the initial orientation.

observer:rotate(right * down)

We offer a pair of equivalent interpretations of the product right * down. They yield the identical result.

  1. right * down can be read from right to left, taking the coördinates of the axis of each rotation to be in the observer’s personal frame. First down pitches him 23° down around the X axis of the observer’s frame, which is parallel at this time to the X axis of the universal frame. This moves his target of view down the solstitial colure to RA 0h Dec 0°, and moves his zenith to Polaris. Then right yaws him 90° to the right around the Y axis of the observer’s frame, which is parallel at this time to the Earth’s axis. This moves his target of view to the right along the celestial equator to the vernal equinox in Pisces. His zenith remains at Polaris.
  2. Alternatively, right * down can be read from left to right, taking the coördinates of the axis of each rotation to be in the universal frame. First right yaws him 90° to the right around the Y axis of the universal frame, which is the axis of the ecliptic. This moves his target of view to the right along the ecliptic to the vernal equinox. His zenith remains at Draco. Then down rolls him 23° counterclockwise around the X axis of the universal frame. But this does not move his target of view at all, since he is already looking along the X axis. It merely causes the picture he sees to roll 23° clockwise. The celestial equator becomes horizontal and his zenith moves along the solstitial colure to Polaris.

The two interpretations leave the user in the same orientation, with the same target of view and the same zenith. We will watch a movie of each interpretation in animateRotation.

Change the Observer:setorientation in the above program to observer:setorientation(down * right) and observe that it has the same effect as the following. observer:rotate(right) observer:rotate(down) It leaves us down in CetusCetus, with Draco above and behind us.

Animate a Rotation

Instead of an instantaneous rotation, let’s make a movie of an observer rotating to his new orientation over a period of several seconds. The method we will use, Observer:gotoObserver:goto, can change his position as well as his orientation. For now, we will change only his orientation.

Observer:goto receives a table of parameters, some of whose field names have embedded uppercase letters. The duration field is the number of real-time seconds it will take to get to the new position; the default value of 5 is in std.durationstd.duration. The accelTime is fraction of the first half of this duration that will be spent in acceleration. The same fraction of the second half will be spent in deceleration. With our accelTime of .5, we will accelerate for the first quarter of the duration (1.25 seconds), move at a constant speed for the middle half (2.5 seconds), and decelerate for the last quarter (1.25 seconds). The accelTime is clamped to the range .1 to 1 inclusive; the default is .5.

The from and to fields are the starting and ending positions, defaulting to the observer’s current position. The initialOrientation and finalOrientation fields are the starting and ending orientations, defaulting to his current orientation. The startInterpolation field is the fraction of the duration that should elapse before the change of orientation begins. The endInterpolation field is the fraction that should elapse after the change of orientation has finished. They default to .25 and .75, which would confine the change of orientation to the middle half of the duration of the goto. Our values of 0 and 1 ensure that the change of orientation will occupy the entire five-second duration.

For the exact path through space and time that the observer will follow during the goto, see Exponential.

--[[
Animate a rotation: take 5 seconds to rotate 23 degrees from Taurus/Gemini
down to Orion.
]]
require("std")

local function main()
	local observer = celestia:sane()
	--Keep the Sun in view, but get away from its glare.
	observer:setposition(std.position)

	wait(std.logo)			--until "CELESTIA" logo disappears
	celestia:mmprint("", 60)	--mark the observer's direction of view

	--A table of parameters for the goto.  The duration, accelTime, from,
	--to, and initialOrientation fields have their default values.

	local parameters = {
		duration = std.duration,
		accelTime = .5,	--accelerate for first quarter of duration

		from = observer:getposition(),
		to = observer:getposition(),

		initialOrientation = observer:getorientation(),
		finalOrientation = celestia:newrotation(std.xaxis, std.tilt),

		startInterpolation = 0,	--0 means start of the duration
		endInterpolation = 1	--1 means end of the duration
	}

	observer:goto(parameters)
	wait(parameters.duration)	--until the goto is finished
end

main()

Now let’s dramatize the first interpretation of right *quaternion multiplication down that we saw in quaternionMultiplication. The following program decomposes it into two rotations, with a 90° change of direction between them, and animates them separately. We end up at the vernal equinox in Pisces with the north celestial polenorth celestial pole overhead.

This program and the following exercise are the most important examples in the book.

--[[
Animate a series of rotations: first down, then right.
]]
require("std")

local function main()
	local observer = celestia:sane()
	--Keep the Sun in view, but get away from its glare.
	observer:setposition(std.position)

	local down  = celestia:newrotation(std.xaxis, std.tilt)
	local right = celestia:newrotation(std.yaxis, math.rad(90))

	wait(std.logo)		--until "CELESTIA" logo disappears
	celestia:mmprint("", 2 * std.duration + 3)

	--Rotate around X axis of observer's frame,
	--parallel to X axis of universal frame.
	local parameters = {
		finalOrientation = down,		--First pitch down.
		startInterpolation = 0,
		endInterpolation = 1
	}
	observer:goto(parameters)
	wait(std.duration)  --Don't do the next goto until this one is finished.

	--Rotate around Y axis of observer's frame,
	--parallel to Earth's axis.
	parameters.finalOrientation = right * down	--Then yaw right.
	observer:goto(parameters)
	wait(std.duration)
end

main()

Decompose the second interpretation of right * down into two rotations and animate them separately. In the above program, change the finalOrientation of the first goto to right. Make no other change. You should still end up at the vernal equinox in Pisces with the north celestial pole overhead.

Turn on the ecliptic grid and animate the two interpretations of the product down * right. In both cases you should end up in CetusCetus the Whale, with the north pole of the ecliptic in Draco above and behind you.

Quaternion multiplication is associativeassociativity of quaternion multiplication. Animate an example using the following rotations. local down = celestia:newrotation(std.xaxis, std.tilt) local right = celestia:newrotation(std.yaxis, math.rad(90)) local clockwise = celestia:newrotation(std.zaxis, math.rad(90)) The two products (clockwise * right) * down
clockwise * (right * down)
should leave us in the same orientation. Starting from the initial orientation std.orientation0, we will be left pointing at the vernal equinox in Pisces with the north celestial pole to our left.

We have two notations for taking the conjugateconjugate of rotation of a rotation (rotateObserver). local down = celestia:newrotation(std.xaxis, std.tilt) local up = down:conjugateRotation:conjugate() local up = down^^ (conjugate operator)-1 --exactly the same We will use the second notation in a foray into the realm of Abstract AlgebraAbstract Algebra. Call setorientation(right * down) to aim the observer at Pisces, with Polaris overhead. Then demonstrate that we can get him back to the initial orientation with either of the following.

observer:rotate((right * down)^-1) observer:rotate(down^-1 * right^-1)

Our mathematician friend would express this as follows. (r1r2)–1 = r2–1r1–1 He or she would say that it opens a vista of great formal beauty.

Congratulations—you have survived your first encounters with quaternion multiplication. Perhaps you have the makings of a planetarium operator.

Transform a Vectortransform a vector

Now that we have rotated the observer, let’s rotate a vector. We’ll take the vector pointing towards Taurus/Gemini and pitch it down through an angle of 23°, pointing it towards RA 6h Dec on the celestial equator in Orion. We will then display the right ascension and declination of this point. For a more substantial example, see Quasar.

--[[
Transform (i.e., rotate) a vector and print the result.
]]
require("std")

--variable used by vectorFormat:
local radecFrame = celestia:newframe("universal", std.radecRotation)

local function vectorFormat(v)
	assert(type(v) == "userdata" and tostring(v) == "[Vector]")

	--Convert v's coordinates from universal frame to radecFrame.
	v = radecFrame:to(v)
	--Convert v's coordinates from Cartesian to spherical.
	local longlat = v:getlonglat()

	--Break down radians into conventional units.
	local ra = std.tora(longlat.longitude)
	local dec = std.todec(longlat.latitude)

	return string.format(
		"(%g, %g, %g)"
		.. " points towards RA %dh %dm %gs "
		.. "Dec %s%d%s %d%s %g%s",
		v:getx(), v:gety(), v:getz(),
		ra.hours, ra.minutes, ra.seconds,
		std.sign(dec.signum),
		dec.degrees, std.char.degree,
		dec.minutes, std.char.minute,
		dec.seconds, std.char.second)
end

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	--vector in universal coordinates pointing towards Taurus/Gemini
	local v = celestia:newvector(0, 0, -1)
	local s = "v = " .. vectorFormat(v) .. "\n"

	local down  = celestia:newrotation(std.xaxis, std.tilt)
	local right = celestia:newrotation(std.yaxis, math.rad(90))

	--vector in universal coordinates pointing towards RA 6h Dec 0 degrees,
	--to the left of Orion's liver.
	local w = down:transformRotation:transform(v)
	assert(type(w) == "userdata" and tostring(w) == "[Vector]")

	--Look in the direction that w is pointing towards,
	--with std.yaxis pointing up.
	assert((w ^ std.yaxis):length() > 0)
	observer:lookat(observer:getposition() + w, std.yaxis)

	s = s .. "w = " .. vectorFormat(w)
	celestia:mmprint(s, 60)
	wait()
end

main()

The .397777 and .917482 are the sine and cosine of 23°.

MM v = (0, 0, -1) points towards RA 6h 0m 0s Dec +23° 26′ 21.448″ w = (-0, -0.397777, -0.917482) points towards RA 6h 0m 0s Dec 0° 0′ 0″

Rotate the Coördinate System

The following program makes the same call to Rotation:transformRotation:transform as the previous program. It does the same arithmetic and prints the same x, y, z coördinates. But it places exactly the opposite interpretation on the output.

This time the vector does not move at all, remaining pointing towards RA 0h Dec on the celestial equator in Orion. We first print the coördinates of the vector with respect to the axes of the J2000 coördinate system in j2000Equatorial, whose XZ plane is the plane of the Earth’s equator. Then we print the coördinates of the vector with respect to the universal frame, whose XZ plane is the plane of the ecliptic.

The rotation down in the previous program has been renamed j2000ToUniversal. Instead of rotating the vector down from the ecliptic to the equator, we can equivalently think of it as rotating the XZ plane up from the equator to the ecliptic. Our vector was on the original XZ plane, and is now below the new XZ plane. The opposite rotation, universalToJ2000, will rotate the plane back down.

--[[
Create the unit vector pointing towards Orion, writing its x, y, z coordinates
in the J2000 equatorial coordinate system: X axis points towards Pisces, Y axis
towards Polaris, Z axis towards Sagittarius (away from Orion).  Keep the vector
pointing towards Orion, but change its x, y, z values from J2000 to universal
coordinates.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)

	--Vector in J2000 equatorial coordinates pointing towards Orion.
	--The vector will always point towards the same point in Orion.
	local v = celestia:newvector(0, 0, -1)
	local s = string.format(
		"J2000 equatorial coordinates of Orion: (%g, %g, %g)\n",
		v:getxyz())

	--a.k.a. down
	local j2000ToUniversal = celestia:newrotation(std.xaxis, std.tilt)
	--a.k.a. up
	local universalToJ2000 = j2000ToUniversal:conjugateRotation:conjugate()

	--Rewrite v in universal coordinates.
	v = j2000ToUniversal:transform(v)
	s = s .. string.format("universal coordinates of Orion: (%g, %g, %g)\n",
		v:getxyz())
	celestia:mmprint(s, 60)

	observer:lookat(observer:getposition() + v, std.yaxis)
	wait()
end

main()
MM J2000 equatorial coordinates of Orion: (0, 0, -1) universal coordinates of Orion: (-0, -0.397777, -0.917482)

Use the opposite quaternion, universalToJ2000, to change universal coördinates back to J2000 equatorial coördinates.

Write std.radecRotationstd.radecRotation in place of j2000ToUniversal. It’s the same quaternion.

Transforming a Vector vs. Rotating an Observer

A vector and an observer respond differently when subjected to the same series of rotations. Let’s start with the vector. In the following example, all coördinates are in the universal frame.

local down = celestia:newrotation(std.xaxis, std.tilt) local right = celestia:newrotation(std.yaxis, math.rad(90)) --Point v towards Taurus/Gemini. local v = celestia:newvector(0, 0, -1) --Point v towards Orion. v = down:transform(v) --Point v towards Cetus. v = right:transform(v) --We could also do both of the above transformations in a single --statement. Note the order of the factors. --v = (down * right):transform(v)

The vector ends up pointing to CetusCetus, 23° below the vernal equinox in Pisces. It got there by pitching down 23° around the universal X axis, and then yawing 90° to the right around the universal Y axis. A vector does not have its own set of axes or its own frame of reference. It always rotates around axes whose coördinates are in the universal frame.

Now let’s subject an observer to the same rotations, down and right. He ends up pointing at the vernal equinox in Pisces, with Polaris overhead. He got there by pitching down 23° around his X axis, and then yawing 90° to the right around his Y axis, which is parallel at this time to the Earth’s Y axis. An observer has a much richer inner life than a vector.

local down = celestia:newrotation(std.xaxis, std.tilt) local right = celestia:newrotation(std.yaxis, math.rad(90)) --Poiont his forward vector towards Taurus/Gemini, --his up vector towards Draco. local observer = celestia:getobserver() --Point his forward vector towards Orion, --his up vector towards Polaris. observer:rotate(down) --Point his forward vector towards Pisces, --his up vector towards Polaris. observer:rotate(right) --We could also do both rotations in a single statement: --Note the order of the factors. --observer:rotate(right * down)

Why do both of the following transformations leave v pointing in the same direction, towards Pisces?

local v = celestia:newvector(0, 0, -1) local right = celestia:newrotation(std.yaxis, math.rad(90)) v = right:transform(v) local v = celestia:newvector(0, 0, -1) local down = celestia:newrotation(std.xaxis, std.tilt) local right = celestia:newrotation(std.yaxis, math.rad(90)) v = (right * down):transform(v)

Create a Rotation from a Cross Product and a Dot Product

What rotation would transform a vector pointing right into a vector pointing up? Obviously, the –90° rotation around the Z axis (0, 0, 1). But our rotations always have a nonegative angle, so we will write it as the 90° rotation around the negative Z axis (0, 0, –1). The 270° rotation around the Z axis would also do the trick, but it’s longer.

local right = std.xaxis --points to the right: (1, 0, 0) local rotation = celestia:newrotation(-std.zaxis, math.rad(90)) local up = rotation:transform(right) --points up: (0, 1, 0)

There’s an easy way to get the shortest rotation that would transform a vector v into another vector w, at least if they are not colinear. The axis of the rotation is supplied by the cross product of v and w; the angle is supplied by the dot product. It’s almost as if the cross product and dot product had been invented precisely for this purpose. (Note that the following Vector:angle is implemented with a dot product). local axis = w ^ v assert(axis:length() > 0) local rotation = celestia:newrotation(axis:normalize(), v:angle(w)) local t = rotation:transform(v) --Within the limits of roundoff error, t == w. assert(t:getx() == w:getx() and t:gety() == w:gety() and t:getz() == w:getz()) See Earthrise for an example.

Conversion Between an Orientation and its Forward/Up Vectors

An orientation determines a forward vector and an up vector. Conversely, two nonzero, non-colinear vectors can be a forward and up vector determining an orientation.

Convert Forward/Up Vectors to an Orientation

The following code takes two vectors, forward and up, and creates the corresponding orientation. Ideally, the vectors are perpendicular. If they are not, the forward vector takes precedence and the up vector is adjusted (erected) to accommodate it as in erect. As usual, zero or colinear vectors would cause a division by zero, probably with spectacular results. We will catch them with an assert.

The orientation is created with Position:orientationtoPosition:orientationto. The position object to which this method belongs is called the source position. Our source position, std.position0std.position0, has the value (0, 0, 0). The arguments of orientationto are the same as the last two arguments of Observer:lookatObserver:lookat. The next-to-last argument is the target position. The vector from the source position to the target position is the forward vector of the new orientation. The last argument is the up vector of the new orientation, erected if necessary.

assert((forward ^ up):length() > 0) --must be nonzero and non-colinear local orientation = std.position0:orientationto(std.position0 + forward, up) --simpler way to do the same thing local orientation = celestia:newrotationforwardupCelestia:newrotationforwardup(forward, up)

To set the observer’s orientation without creating a separate orientation object, call Observer:lookat.

assert((forward ^ up):length() > 0) observer:lookat(observer:getposition() + forward, up)

Convert an Orientation to Forward/Up vectors

The following code takes an orientation and creates the corresponding forward and up vectors. The std.forwardstd.forward and std.upstd.up are the forward and up vectors of the initial orientation. The orientation object is simply the rotation that would get us to the desired orientation from the initial orientation.

local forward = orientation:transform(std.forward) local up = orientation:transform(std.up) --simpler way to do the same thing local forward, up = orientation:getforwardupRotation:getforwardup()

The Other Frames of Reference

[The creatures] were not standing quite vertically in relation to the floor of the valley: but to RansomRansom, Elwyn it appeared … that [they] were vertical. It was the valley—it was the whole world of Perelandra—which was aslant.… It was borne in upon him that the creatures were really moving, though not moving in relation to him. This planet which inevitably seemed to him while he was in it an unmoving world—the world, in fact—was to them a thing moving through the heavens. In relation to their own celestial frame of reference they were rushing forward to keep abreast of the mountain valley. Had they stood still, they would have flashed past him too quickly for him to see, doubly dropped behind by the planet’s spin on its own axis and by its onward march around the Sun.
C. S. LewisLewis, C. S., PerelandraPerelandra (Lewis) (1943)

A frame of reference serves two purposes in Celestia. First, the (x, y, z) coördinates of a position or vector are measured with respect to the X, Y, Z axes of a frame. Second, an observer can adhere to a frame, changing his position and orientation as the frame changes its position and orientation. By default, the frame to which the observer adheres is the universal frame in universalFrame, which is immobile. But there are many other frames.

The Names of the Frames

Celestia offers six sets of frames, identified by the case-sensitive strings passed to Celestia:newframe and returned from Frame:getcoordinatesystem. An alternative list of strings is used in a cel: URL. Yet another list appears in the lower right corner of the Celestia window, which displays the frame to which the active observer has been setframed. See Celestia:setoverlayelements.

argument of
Celestia:newframe
return value of
Frame:getcoordinatesystem
in argument of
Celestia:seturl
in return value of
Celestia:geturl
lower right corner
of Celestia window
"universal" "Freeflight" ""
"ecliptic" "Follow" "Follow"
"equatorial" "Unknown" ""
"bodyfixed" "SyncOrbit" "Sync Orbit"
"chase" "Chase" "Chase"
"lock" "PhaseLock" "Lock"

There is only one universal frame for all of Celestia. See universalFrame.

There is one ecliptic, equatorial, bodyfixed, and chase frame for each celestial object. The object is called the reference objectreference object of frame of the frame, and may be a planet, star, spacecraft, a surface feature of a celestial object, or even an abstraction such as the Solar System Barycenter. In fact, the reference object can be any object found with Celestia:find.

There is one lock frame for each ordered pair of distinct celestial objects. They are called the reference object and the target objecttarget object of lock frame of the frame. The Earth and Mars have one lock frame, and Mars and the Earth have another. But the Earth and the Earth do not have a lock frame: it takes two to tango.

The parentheses( ) (in pattern) in the following patternpattern (Lua) delimit a capturecapture (in pattern), which is a substring to be returned by the string.matchstring.find method.

--[[
Print the name of the observer's current frame of reference.
]]
require("std")

local function main()
	local observer = celestia:sane()
	--Keep the Sun in view, but get away from its glare.
	observer:setposition(std.position)

	local frameFrame (class) = observer:getframeObserver:getframe()
	assert(type(frame) == "userdata" and tostring(frame) == "[Frame]")

	local system = frame:getcoordinatesystemFrame:getcoordinatesystem()
	assert(type(system) == "string")
	local s = "The observer currently adheres to the " .. system .. " frame"

	if system ~= "universal" then
		local reference = frame:getrefobjectFrame:getrefobject()
		assert(type(reference) == "userdata" and
			tostring(reference) == "[Object]")
		s = s .. "\nwhose reference object is " .. reference:name()

		if system == "lock" then
			local target = frame:gettargetobjectFrame:gettargetobject()
			assert(type(target) == "userdata" and
				tostring(target) == "[Object]")
			s = s .. "\nand whose target object is "
				.. target:name()
		end
	end

	s = s .. ".\n"
	local url = celestia:geturl()
	local pattern = "^^ (anchor in pattern)cel://(.++ (in pattern))/"	--the (.+) is a capture
	local name = url:match(pattern)
	s = s .. "The name of the frame in a URL is " .. name .. "."
	celestia:print(s, std.duration)
end

main()
The observer currently adheres to the universal frame. The name of the frame in a URL is Freeflight.

Overview of the Frames

Which way is up? How do you want your artificial horizon to define up and down? By reference to the earth’s horizon, or to the moon’s up and down, or to some other vector which will remain constant for the entire flight, or what?
Michael CollinsCollins, Michael, Carrying the Fire: An Astronaut’s JourneysCarrying the Fire: An Astronaut’s Journey (1974)

Each frame has an origin and three perpendicular axes named X, Y, Z. The Z axis for any frame is always determined by the X and Y axes using the right-hand ruleright-handed frame in framesOfReference. The following table groups the frames by their fundamental planefundamental plane (of frame) (framesOfReference).

The primaryprimary (of satellite) of a satellite is the object around which it revolves. For example, the primary of the Earth is the Sun.

origin fundamental plane X axis Y axis Z axis
universal
(universalFrame)
Solar System Barycenter XZ plane is
plane of ecliptic
towards J2000
vernal equinox
in Pisces
towards
north pole
of ecliptic
in Draco
towards J2000
winter solstice
in Sagittarius
ecliptic
(eclipticFrame)
center of
reference object
XZ plane is
plane of ecliptic
towards J2000 vernal equinox in Pisces towards north pole of ecliptic in Draco towards J2000 winter solstice in Sagittarius
equatorial
(equatorialFrame)
center of
reference object
XZ plane is
plane of
reference object’s equator
towards reference object’s current vernal equinox towards north pole of reference object (south pole for Venus) towards reference object’s current winter solstice (summer for Venus)
bodyfixed
(bodyfixedFrame)
center of
reference object
XZ plane is
plane of reference object’s equator
towards reference object’s N E towards north pole of reference object (south pole for Venus) towards reference object’s N 90° W (90° E for Venus)
chase
(chaseFrame)
center of
reference object
XY plane is plane of reference object’s orbit around primary away from direction of reference object’s motion w/r/t its primary towards center of reference object’s primary (erected) determined by
X and Y axes
lock
(lockFrame)
center of
reference object
XY plane is plane of reference object’s motion w/r/t target object towards center of target object away from direction of reference object’s motion w/r/t target object (erected) determined by
X and Y axes

The Y axis of the equatorial and bodyfixed frames points north for most objects, at least in the sense of pointing less than 90° away from the Y axis of the universal frame. But an object with retrograde rotation has a Y axis that points south. Examples include VenusVenus, retrograde rotation of, UranusUranus, retrograde rotation of and its satellites, PlutoPluto, retrograde rotation of and its satellites, and the asteroid 243 IdaIda (asteroid) and its satellite DactylDactyl (asteroid). Thus a rotating object always spins counterclockwise when viewed from a point on its positive Y axis. An object with no rotationrotation, lack of has the Y axis of the universal frame. Examples include "Sol/Earth/Washington D.C.", the Solar System BarycenterSolar System barycenter, lack of rotation of, and the Milky WayMilky Way, lack of rotation of. Stars other than the Sun also have the Y axis of the universal frame.

The X axis of the chase frame is defined in the above table. But the Y axis in the table is only an approximation to the real Y axis, which has to be exactly perpendicular to the X axis. The Y axis in the table is actually erected (adjusted, erect) to become perpendicular to the X axis. The real Y axis of the chase frame lies in the plane of the X and Y axes in the table, and is perpendicular to the X axis. The Y axis of the lock frame is handled similarly.

The Celx Standard Library lets us create a new frame of reference by rotating one of Celestia’s existing frames. For example, consider the set of axes in j2000Equatorial for the J2000 equatorial coördinates. Its origin is the Solar System Barycenter and its XZ plane is parallel to the plane of the Earth’s equator. The X axis points towards the vernal equinox in Pisces, and the Y axis towards the north celestial pole near Polaris. We can implement this frame as a 23° rotation of Celestia’s universal frame. We could also implement it as a 23° rotation of the Earth’s ecliptic frame; in this case the origin would be the center of the Earth. See rotatedRadec.

Other examples of “rotated” frames are the galactic and supergalactic coördinates (galacticCoordinates and supergalacticCoordinates), implemented as rotations of the universal or an ecliptic frame. And an altazimuth frame (celestialDome) for a point on the surface of a celestial object can be implemented as a rotation of the bodyfixed frame for that object. The angle and axis of the rotation depends on the latitude and longitude of the point.

Finally, each observer has his own personal frameobserver’s personal frame, a set of axes keyed to his current orientation. This frame is used only by Observer:rotateObserver:rotate (rotateObserver) and Observer:orbitObserver:orbit (orbitObserver). For example, the following code rotates the observer around an axis that extends to his current right. This axis is not necessarily parallel to the X axis of the universal frame.

--Pitch the observer down. local down = celestia:newrotation(std.xaxis, std.tilt) observer:rotate(down)

An Ecliptic Frameecliptic frame

Each celestial object has its own ecliptic frame of reference. The object is called the reference object of the frame. The ecliptic frame identical to the universal frame except that its origin is at the center of the reference object. If the reference object moves, the ecliptic frame moves with it.

The axes of an ecliptic frame remain parallel to those of the universal frame. The XZ plane is therefore parallel the plane of the ecliptic. The X axis points towards the vernal equinox at RA 0h in Pisces. The Z axis points towards the winter solstice at RA 18h in Sagittarius. The Y axis points towards the north pole of the ecliptic in Draco.

Let’s verify that the X axis of the Earth’s ecliptic frame points from the center of the planet towards Pisces, and the Y axis towards Draco. We will position the observer on the negative X axis. Looking towards the origin with the Y axis pointing up, the Earth should appear directly in front of us with Pisces behind it. Draco should be overhead because the red line of the ecliptic is horizontal.

The position object initially has its coördinates measured with respect to the Earth’s ecliptic frame. But a position or vector passed to Observer:setposition or Observer:lookat must always be in the coördinates of the universal frame. (This is true even if the observer has been setframeObserver:setframed to another frame.) The position must therefore be converted from the Earth’s ecliptic frame to the universal frame by the method Frame:fromFrame:from. The resulting object still represents the same position in space, but the values of its (x, y, z) coördinates have been changed. The conversion must be performed with Frame:from rather than Rotation:transformRotation:trasnform because the universal and ecliptic frames have different origins.

The Earth’s ecliptic frame is in constant motion as the Earth revolves around the Sun. By default, Frame:from translates a position from the ecliptic frame of the current time to the universal frame. An optional second argument of Frame:from lets us specify a different time.

The native Celx Frame:from accepts a position or an orientation as its argument. The standard library lets this method accept a vector as well, permitting us to create the up vector passed to Observer:lookat. (In this example we could change the last argument of lookat to plain old std.yaxis, because the axes of an ecliptic frame are parallel to those of the universal frame. But for most other frames, this would not be true.)

The opposite of Frame:from is Frame:toFrame:to. It converts a position, orientation, or vector from some other frame to the universal frame. When we convert the position of the Earth from the universal frame to the Earth’s ecliptic frame, we always get (0, 0, 0). The center of the Earth is always at the origin of the Earth’s ecliptic frame.

--[[
Demonstrate that the X axis of the Earth's ecliptic frame points towards Pisces,
and the Y axis towards Draco.  Position the observer on the negative X axis and
look towards the origin, with the Y axis pointing up.
]]
require("std")

local function main()
	local observer = celestia:sane()
	local earth = celestia:find("Sol/Earth")
	local eclipticFrame = celestia:newframe("ecliptic", earth)

	--Adhere the user to the eclipticFrame so he moves along with it.
	--The setframe must come *before* the setposition.
	observer:setframe(eclipticFrame)

	--A position on the negative X axis of Earth's ecliptic frame.
	--The coordinates of the position are in Earth's ecliptic frame.
	local microlightyears = 12 * earth:radius() / KM_PER_MICROLY
	local position = celestia:newposition(-microlightyears, 0, 0)

	local s = string.format(
		"Observer in Earth's ecliptic frame: (%g, %g, %g)\n",
		position:getxyz())

	--The same position, but convert the coordinates
	--from Earth's ecliptic frame to the universal frame.
	position = eclipticFrame:from(position)
	observer:setposition(position)

	s = s .. string.format(
		"Observer in universal frame: (%g, %g, %g)\n",
		position:getxyz())

	--The position of the Earth in the coordinates of the universal frame.
	local universalPosition = earth:getposition()
	s = s .. string.format(
		"Center of Earth in universal frame: (%g, %g, %g)\n",
		universalPosition:getxyz())

	--The position of the Earth in the coordinates of the Earth's ecliptic
	--frame.  It will be (0, 0, 0) by definition.
	local eclipticPosition = eclipticFrame:to(earth:getposition())
	s = s .. string.format(
		"Center of Earth in Earth's ecliptic frame: (%g, %g, %g)\n",
		eclipticPosition:getxyz())

	--A vector pointing along the Y axis of Earth's ecliptic frame.
	--The coordinates are in Earth's ecliptic frame.
	local up = celestia:newvector(0, 1, 0)	--or std.yaxis

	--The same vector, but convert the coordinates from Earth's ecliptic
	--frame to the universal frame.  (This conversion happens to leave the
	--coordinates unchanged, but will be necessary for other frames.)
	up = eclipticFrame:from(up)

	observer:lookat(universalPosition, up)
	celestia:print(s, 60)
	wait()
end

main()
Observer in Earth's ecliptic frame: (-0.00809004, 0, 0) Observer in universal frame: (1.67688, 0.00018605, 16.014) Center of Earth in universal frame: (1.68497, 0.00018605, 16.014) Center of Earth in Earth's ecliptic frame: (0, 0, 0)

Write std.yaxis instead of Celestia:newvector(0, 1, 0). It’s the same vector.

Verify that the Z axis of the Earth’s ecliptic frame points towards the winter solstice in Sagittarius. Let the observer’s position be

--coordinates in the Earth's ecliptic frame local position = celestia:newposition(0, 0, -microlightyears)

Then verify that the Y axis points towards the north pole of the ecliptic in Draco. The observer’s forward and up vectors cannot be colinear, so we will have to choose a different up vector.

--Coordinates in the Earth's ecliptic frame. local position = celestia:newposition(0, -microlightyears, 0) --Coordinates in the Earth's ecliptic frame. --Make solstitial colure vertical, with Polaris towards top of window. local up = celestia:newposition(0, 0, -1)

Position the observer on the negative X axis of the Earth’s ecliptic frame, looking at hte Earth with the Y axis pointing up. Verify that he remains there even if we speed up the time.

--one minute of simulation time per second of real time celestia:settimescale(60)

Then comment out the call to Observer:setframe, run the program again, and watch the Earth sail out of the window. The moral is that the observer’s frame should be set before his position and orientation.

Display colored arrows along the axes of the Earth’s ecliptic frame. local mark = {type = "frame axesframe axes"} --a table with one field earth:addreferencemark(mark) The table can be an anonymous object. earth:addreferencemark({type = "frame axes"}) Since the only argument of the method is a table with curly braces, the parentheses( ) (omitted for table argument) can be omitted. earth:addreferencemark {type = "frame axes"}

To see all three arrows, toggle the OpenGL wireframe mode with control-w. The arrows are labeled with unexpected names. The red “X” arrow does indeed point along the positive X axis of the Earth’s ecliptic frame. But the blue “Z” arrow points along the positive Y axis, and the green “Y” arrow points along the negative Z axis.

Verify that the axes of an ecliptic frame remain parallel to those of the universal frame even if the reference object belongs to a different solar system. Try the extrasolar planet "Pollux/b"Pollux/b (extrasolar planet).

Read the from and to methods in the metatable for class Frame in the standard library file std.lua in stdCelx.

The following program should position the observer at the center of the Earth. On Macintosh, it works correctly. But on Microsoft Windows, it places him at an unpredictable location, often lightyears—or megaparsecs—from the Earth. The same unpredictable behavior happens when Celestia is compiled from the source code on Macintosh, Microsoft Windows, or Linux.

--[[
Position the Observer at the center of the Earth,
i.e., at the origin of Earth's ecliptic frame.
]]
require("std")

local function main()
	local observer = celestia:sane()
	local earth = celestia:find("Sol/Earth")
	celestia:select(earth)	--to display the observer's distance from Earth
	earth:setvisible(false)	--so the observer can see out of the Earth

	local eclipticFrame = celestia:newframe("ecliptic", earth)
	observer:setframe(eclipticFrame)

	observer:setposition(earth:getposition())
	wait()
end

main()

The bugbug in Celestia is in the C++ member function Observer::orbit in the file version/src/celengine/observer.cpp. It creates a vector v pointing from the origin of the observer’s frame to his position. In this example, the origin and the position are the same point, so v is of length zerodivision by zero and is impossible to normalize.

The solution is to change the following code in Observer::orbit

v.normalize(); v *= distance;

to

if (distance > 0) { //distance is the length of v v.normalize(); v *= distance; }

Before you run the Celx program, can you predictnan (not a number) the Earth’s phase angle for an observer at the centernot a number (nan) of the Earth?

Apparent Retrograde Motionretrograde motion, apparent of Mars

Let’s peek over the Earth’s shoulder at the retrograde motionapparent retrograde motion of MarsMars, retrograde motion of. We’ll pick the oppositionopposition of December 24, 2007 because Mars was then in Taurus/Gemini, the target of the observer’s initial orientation.

--[[
Show the apparent retrograde motion of Mars as seen from Earth
during the opposition of December 2007.
]]
require("std")

local function main()
	local observer = celestia:sane()
	celestia:hide("grid", "orbits")
	celestia:show("eclipticgrid")
	celestia:hidelabel("moons", "planets", "spacecraft")

	local earth = celestia:find("Sol/Earth")
	local mars = celestia:find("Sol/Mars")
	celestia:select(mars)

	local eclipticFrame = celestia:newframe("ecliptic", earth)
	--The observer will move with the Earth,
	--but will keep the same orientation.
	observer:setframe(eclipticFrame)

	local microlightyears = 25 * earth:radius() / KM_PER_MICROLY
	local position = celestia:newposition(0, 0, microlightyears)
	observer:setposition(eclipticFrame:from(position))

	celestia:settime(celestia:utctotdb(2007, 10, 15))
	--4 days of simulation time per second of real time.
	celestia:settimescale(60 * 60 * 24 * 4)
	wait()
end

main()

In traceRetrograde we will use OpenGL to draw the loop that Mars makes on the surface of the celestial sphere.

After setting the observer’s frame, you can set his orientation to whatever you want. The most bland example would be the orientation we already have.

--The initial orientation points towards Taurus/Gemini, with Draco up. local forward = celestia:newvectorradec(math.rad(360 * 6 / 24), std.tilt) local up = celestia:newvectorradec(math.rad(360 * 18 / 24), math.rad(90) - std.tilt) local orientation = celestia:newrotationforwardup(forward, up) observer:setorientation(orientation) --Simpler ways to create the same orientation: --local orientation = celestia:newrotationforwardup(std.forward, std.up) --local orientation = std.orientation0

Display a caption as Mars’s direction of apparent motion changes: direct, retrograde, and direct again.

--variables used by tickHandler: local earth = celestia:find("Sol/Earth") local mars = celestia:find("Sol/Mars") local eclipticFrame = celestia:newframe("ecliptic", earth) local oldLongitude = nil --Mars's ecliptic longitude at previous tick local function tickHandler() --coordinates in universal frame local marsPosition = mars:getposition() --coordinates converted to Earth's ecliptic frame marsPosition = eclipticFrame:to(marsPosition) local longitude = marsPosition:getlonglat().longitude if oldLongitude ~= nil then --there was a previous call to tickHandler local s = nil if longitude > oldLongitude then --Mars is moving right to left s = std.char.larr .. " direct" --left arrow elseif longitude < oldLongitude then --moving left to right s = "retrograde " .. std.char.rarr else s = "stationary" end celestia:print(s, 1, 0, 0, -3, -5) end oldLongitude = longitude end --in the main function celestia:registereventhandler("tick", tickHandler) ← direct retrograde → ← direct

The above tickHandler has two bugs. If Mars’s longitude is 359° at one tick and at the next, its motion is direct but the tickHandler prints "retrograde". Conversely, if the longitude is at one tick and then 359° at the next, the motion is retrograde but the tickHandler prints "direct". Fix the bugs in the simplest way.

Did the furiously rotating Earth distract you? If so, keep the Earth out of the picture by watching the retrograde motion from the side of the Earth facing Mars. Position the observer along the negative Z axis of the Earth’s ecliptic frame, at the top of the Earth’s atmosphere above the sub-summer-solstice point. Change the above call to Observer:setposition to the following. Don’t forget the uppercase H in atmosphereHeight.

local kilometers = earth:radius() + earth:getinfo().atmosphereHeight local microlightyears = kilometers / KM_PER_MICROLY local position = celestia:newposition(0, 0, -microlightyears) observer:setposition(eclipticFrame:from(position))

To verify that the Earth remains right behind us, too close to focus on, press ** (rear-view mirror command) (asterisk, the rear-view mirror command) while the program is running. Then press asterisk again to restore the view forward.

A simpler way to keep the Earth out of the picture is to make it invisible. This will let us position the observer at the center of the Earth.

earth:setvisible(false) local position = celestia:newposition(0, 0, 0) --or std.position0 observer:setposition(eclipticFrame:from(position)) --or simply --observer:setposition(earth:getposition())

The Tychonic Solar SystemTychonic Solar System

[I]t is difficult to imagine any physical mechanism that could produce planetary motions even approximately like BraheTycho Brahe’s.
Thomas S. KuhnKuhn, Thomas S., The Copernican RevolutionCopernican Revolution, The (Kuhn) (1957)

Tycho Brahe’s solar system was a compromise. His Sun revolved around a fixed Earth, and the other planets revolved around the Sun. Let’s simulate this.

--[[
View the Tychonic solar system from above the Earth, looking down.
Keep the Earth motionless in the center of the window,
in front of the south pole of the Ecliptic in Dorado.
The summer solstice in Taurus/Gemini is towards the top of the window.
]]
require("std")

local function main()
	local observer = celestia:sane()
	celestia:hide("constellations", "grid")	--distracting
	celestia:hidelabel("constellations")
	celestia:show("orbits")

	local earth = celestia:find("Sol/Earth")
	local eclipticFrame = celestia:newframe("ecliptic", earth)
	observer:setframe(eclipticFrame)

	local position = celestia:newposition(0, 128, 0)
	observer:setposition(eclipticFrame:from(position))
	observer:lookat(eclipticFrame:from(std.position0), std.forward)

	--2 days of simulation time per second of real time
	celestia:settimescale(60 * 60 * 24 * 2)
	wait()
end

main()

In traceRetrograde we will use OpenGL to draw the loops that Mars makes relative to the stationary Earth. These are the loops generated by the deferents and epicycles of the Ptolemaic solar systemPtolemaic solar system.

In the Copernican Solar SystemCopernican Solar System, the planets go around the Sun. Simulate this solar system with an ecliptic frame whose reference object is the Sun. In the Newtonian Solar System, the Sun and planets go around the Solar System Barycenter. Simulate this solar system with the universal frame.

In the Newtonian Solar System, make the planets big enough to see. For example,

local earth = celestia:find("Sol/Earth") local kilometers = earth:radius() earth:setradius(2400 * kilometers)

A Rotated Framerotated frame: Right Ascension and Declination

[Y]ou may have to build your own [programming language]. That does not mean that you should go off and write a compiler, however; that is a job for experts. Instead, you should learn how to enhance whatever existing language you have with one or more preprocessors.
Brian W. KernighanKernighan, Brian W. and P. J. PlaugerPlauger, P. J., Software Tools (1976)

Let’s display the right ascension and declination of BetelgeuseBetelgeuse (star) in OrionOrion as seen from the center of the Earth. The ideal frame of reference for this purpose would have its origin at the center of the Earth, with its XZ plane in the plane of the Earth’s J2000 equator. The X axis would point towards the vernal equinox in Pisces, and the Y axis towards the north celestial polenorth celestial pole near Polaris. This frame would be identical to the Earth’s ecliptic frame, except that it would be tilted therefrom by a rotation of 23° around the X axis of the ecliptic frame.

The Celx Standard Library lets us create the ideal frame very easily: the following radecRotation is the rotation, and the radecFrame is the frame. The method Frame:to converts the coördinates of the position from the universal frame (Y axis pointing towards Draco) to the radecFrame (Y axis pointing towards Polaris). Then Position:getlonglat converts the position’s Cartesian coördinates into spherical coördinates, including a radial coördinate giving the position’s distance in microlightyears from the origin. The function std.torastd.tora breaks down the radians of longitude into hours, minutes, and seconds of right ascension, and std.todecstd.todec breaks down the radians of latitude into degrees, minutes, and seconds of declination. std.todec also provides the numeric field signumsignum (of declination), which is 1 for north, –1 for south, or 0 for the equator. The function std.signstd.sign converts these numbers into the strings "+", "-", or "".

--[[
Print the right ascension and declination of Betelgeuse
as seen from the center of the Earth.
]]
require("std")

local function main()
	local observer = celestia:sane()
	observer:setposition(std.position)

	local earth = celestia:find("Sol/Earth")
	local betelgeuse = celestia:find("Betelgeuse")
	celestia:select(betelgeuse)

	local radecRotation = celestia:newrotation(std.xaxis, std.tilt)
	local radecFrame = celestia:newframeCelestia:newframe("ecliptic", earth, radecRotation)

	--[[
	In the universal frame, std.yaxis points towards Draco.
	In the radecFrame, std.yaxis points towards Polaris.
	In the universal frame, up points towards Polaris.
	]]
	local position = betelgeuse:getposition()
	local up = radecFrame:from(std.yaxis)
	observer:lookat(position, up)

	local s = string.format("Betelgeuse:\n"
		.. "Position in universal frame: (%.15g, %.15g, %.15g)\n",
		position:getxyz())

	--Convert the coordinates of the position from the universal frame
	--to the radecFrame.
	position = radecFrame:to(position)
	s = s .. string.format(
		"Position in radecFrame: (%.15g, %.15g, %.15g)\n",
		position:getxyz())

	--Convert to polar coordinates.
	local longlat = position:getlonglat()
	assert(type(longlat) == "table")

	--Convert radians to hours, minutes, seconds of right ascension.
	local ra = std.tora(longlat.longitude)
	assert(type(ra) == "table")

	--Convert radians to degrees, minutes, seconds of declination.
	local dec = std.todec(longlat.latitude)
	assert(type(dec) == "table")

	s = s .. string.format(
		"RA %dh %dm %gs\n"
		.. "Dec %s%d%s %d%s %g%s",
		ra.hours, ra.minutes, ra.seconds,
		std.sign(dec.signum),
		dec.degrees, std.char.degree,
		dec.minutes, std.char.minute,
		dec.seconds, std.char.second)

	celestia:print(s, 60, -1, -1, 1, 6)	--5 lines of text
	wait()
end

main()
Betelgeuse: Position in universal frame: (10402991.2948608, -137483581.542969, -478496673.583984) Position in radecFrame: (10402989.5836485, 64196332.3193992, -493699957.529088) RA 5h 55m 10.2892s Dec +7° 24′ 25.3318″

Use the existing std.radecRotationstd.radecRotation instead of creating your own radecRotation. It’s the same rotation.

How much of a difference would it make if we measured the right ascension and declination of Betelgeuse as seen from the Solar System Barycenter? Create a radecFrame whose origin is the barycenter. It will be identical to the universal frame, but tilted 23° therefrom.

local radecFrame = celestia:newframe("universal", std.radecRotation)

Find the right ascension and declination of the Moon as seen from "Sol/Earth/Washington D.C." at 12:00 UTC on January 1, 2014. For now, just print the numbers. We will position the observer and draw the picture when we have the bodyfixed frame in bodyfixedFrame.

--one space, no comma, two periods local washington = celestia:find("Sol/Earth/Washington D.C.") local radecFrame = celestia:newframe("ecliptic", washington, std.radecRotation)

Read the program in j2000Equatorial that displays the current right ascension and declination of the observer’s direction of view.

To create a rotated frame from an existing frame, we have supplied the axis and angle of the rotation. To create a rotated frame from an existing frame by supplying the X, Y, and Z axes of the rotated frame, see johnGlenn and Analemma.

Galactic Coördinatesgalactic coördinates ℓ and b

The following galacticFrame is the same as the universal frame, but is tilted therefrom by the std.galacticRotationstd.galacticRotation. For those who are interested, the angle of the std.galacticRotation is approximately 174° and the axis of rotation points towards the lower reaches of EridanusEridanus. But it is much more important to know where the axes of the galacticFrame are pointing.

The galacticFrame has the origin as the universal frame, the Solar System Barycenter. The XZ plane of the galacticFrame is the plane of the Milky Way galaxy. The X axis points towards the galactic centergalactic center in SagittariusSagittarius; the Z axis towards VelaVela the Sail. The Y axis points towards the north galactic polenorth galactic pole (NGP) (NGPNGP (north galactic pole)) in Coma BerenicesComa Berenices, and away from the south galactic polesouth galactic pole (SGP) (SGPSGP (south galactic pole)) in SculptorScultor.

Galactic longitudegalactic longitude (ℓ) is denoted by ℓ (galactic longitude). This character (a script lowercase L) is in the standard library as std.char.lstd.char.l (galactic longitude). Galactic latitudegalactic latitude (b) is denoted by an italic bb (galactic latitude). Looking down at the XZ plane from Coma Berenices, the positive Y axis points straight towards us and is invisible:

For a totally different system of coördinates whose origin is at the center of the galaxy, see the bodyfixed frame in alternativeBodyfixed.

The vector forward in the following main function is (1, 0, 0). Interpreted in universal coördinates, it would therefore point towards RA 0h Dec in Pisces. But we will interpret it as a vector in galactic coördinates, pointing towards the galactic center at ℓ = b = in Sagittarius. Before we use it to build an orientation, we must convert its coördinates from the galacticFrame to the universal frame.

--[[
Display the galactic coordinates of the direction in which the observer is
facing.  He is initially facing the galactic center in Sagittarius, with the
north galactic pole in Coma Berenices overhead.
To change his orientation, press the arrow keys or drag on the sky.
]]
require("std")

--variables used by tickHandler:
local observer = celestia:sane()
local galacticFrame = celestia:newframe("universal", std.galacticRotation)

local function tickHandler()
	--Get the observer's forward vector in universal frame coordinates.
	local forward = observer:getorientation():transform(std.forward)

	--Rewrite the observer's forward vector in galacticFrame coordinates.
	forward = galacticFrame:to(forward)

	--Convert cartesian coordinates to spherical.
	local longlat = forward:getlonglat()

	local s = string.format(
		"%s = %.2f%s\n"
		.. "b = %.2f%s",
		std.char.l, math.deg(longlat.longitude), std.char.degree,
		math.deg(longlat.latitude), std.char.degree)

	celestia:mmprint(s, 1)
end

local function main()
	observer:setposition(std.position)	--Get away from Sun's glare.
	celestia:hide("grid", "ecliptic")
	celestia:show("galacticgridgalacticgrid (renderflag) ")
	--celestia:setgalaxylightgainCelestia:setgalaxylightgain(.75)    --minimum 0, maximum 1, default .5

	--Point the observer at the center of galaxy,
	--with north galactic pole overhead.

	local forward = celestia:newvectorlonglat(math.rad( 0), math.rad( 0))
	local up      = celestia:newvectorlonglat(math.rad( 0), math.rad(90))

	local orientation = celestia:newrotationforwardup(
		galacticFrame:from(forward),
		galacticFrame:from(up))

	observer:setorientation(orientation)
	celestia:registereventhandler("tick", tickHandler)
	wait()
end

main()
MM ℓ = 360.00° b = -0.00°

To remove any jitterjitter (of numeric value) in the values of ℓ and b, see celestialDome. To display 360° as 0°, see j2000Equatorial.

The argument of Frame:fromFrame:from can be a Rotation instead of a Vector. In the above main function, we can therefore change local orientation = celestia:newrotationforwardup( galacticFrame:from(forward), galacticFrame:from(up)) observer:setorientation(orientation) to local orientation = celestia:newrotationforwardup(forward, up) observer:setorientation(galacticFrame:from(orientation))

The radio source Sagittarius A*Sagittarius A* (radio source) is a supermassive black holeblack hole at RA 17h 45m 40.0409s Dec –29° 0′ 28.118″. Since it’s at the center of the Milky Way, its galactic coördinates should be close to ℓ = 0°, b = 0°. Are they?

--RA and Dec of Sagittarius A* in radians local ra = math.rad(( 17 + (45 + 40.0409 / 60) / 60) * 360 / 24) local dec = math.rad( -29 - ( 0 + 28.118 / 60) / 60) local toSagittariusA = celestia:newvectorradec(ra, dec) --Convert vector from universal frame to galacticFrame. toSagittariusA = galacticFrame:to(toSagittariusA) --Convert cartesian coordinates to spherical. local longlat = toSagittariusA:getlonglat() local s = string.format( "%s = %.15g%s\n" .. "b = %.15g%s", std.char.l, math.deg(longlat.longitude), std.char.degree, math.deg(longlat.latitude), std.char.degree) ℓ = 359.944285593245° b = -0.0461371244601728°

The Hubble Deep FieldHubble Deep Field is a small area of the sky at RA 12h 36m 49.4s Dec 62° 12′ 58″ in Ursa MajorUrsa Major. It was chosen to be well away from the plane of the galaxy, which is filled with gas and dust. What is the field’s galactic latitude (b)?

The two galaxies [the Milky WayMilky Way, direction of rotation of and AndromedaAndromeda Galaxy (M31)] rotate in complementary directions, one clockwise and the other counterclockwise, so to speak; this characteristic of their relationship, found in many other pairs of spirals as well, lends support to the hypothesis that the two galaxies formed at about the same time, from two adjacent whirlpools of primordial gas, rather than having formed far apart and later blundering into each other’s company.
Timothy FerrisFerris, Timothy, GalaxiesGalaxies (Ferris) (1980)

In the real universe, the Milky Way and the Andromeda Galaxy ("M 31"M31 (Andromeda Galaxy)) spin in opposite directions. Demonstrate that in the Celestia universe, they spin in the same direction.

The following program displays the Milky Way centered in the window. The observer is above the north pole of the galaxy; the galactic grid confirms that he is looking down, towards the south galactic pole. The trailing arms show that the galaxy spins clockwise, as in the real universe.

--[[
Position the observer above the north pole of the Milky Way
and look down at the galaxy.
]]
require("std")

local function main()
	local observer = celestia:sane()
	celestia:hide("ecliptic", "grid")
	celestia:show("galacticgrid")	--Look towards south galactic pole.

	local galacticFrame =
		celestia:newframe("universal", std.galacticRotation)
	observer:setframe(galacticFrame)

	local galaxy = celestia:find(
		"Milky Way"
		--"M 31"
	)
	local microlightyears = 5 * galaxy:getinfo().radius * 1000000
	local vector = celestia:newvector(0, microlightyears, 0)
	local position = galaxy:getposition() + galacticFrame:from(vector)

	observer:setposition(position)
	observer:lookat(galaxy:getposition(), galacticFrame:from(std.xaxis))
	wait()
end

main()

Now change the galaxy to "M 31" (with one space). Note that the observer remains in the same orientation, pointing towards the south pole of the galactic grid. (The galactic grid is still the grid of the Milky Way.) The Andromeda Galaxy is centered in front of him, although it does not lie perfectly flat in the plane of the window. Its trailing arms show that it spins clockwise.

Supergalactic Coördinatessupergalactic coördinates SGL and SGB

The supergalactic planesupergalactic plane is the plane of the nearest superclusterssupercluster of galaxies, including the Hydra-Centaurus SuperclusterHydra-Centaurus Supercluster the Perseus-Pisces SuperclusterPerseus-Pisces Supercluster, the Pavo-Indus SuperclusterPavo-Indus Supercluster, and our own Virgo SuperclusterVirgo Supercluster. Above and below the plane lie the NorthernNorthern Local Supervoid and SouthernSouthern Local Supervoid Local Supervoidsvoid (intergalactic).

The following supergalacticFrame is the same as the universal frame, but is tilted therefrom by the std.supergalacticRotationstd.supergalacticRotation. The XZ plane of the supergalacticFrame is the supergalactic plane. The X axis points towards CassiopeiaCassiopeia, where the galactic and supergalactic planes cross each other. The Y axis points towards the north supergalactic poleat galactic coördinates ℓ = 47.37°, b = +6.32° in HerculesHercules; the south polesouth supergalactic pole is in Canis MajorCanis Major.

Supergalactic longitudesupergalactic longitude (SGL) and latitudesupergalactic latitude (SGB) are denoted by uppercase SGLSGL (supergalactic longitude) and SGBSGB (supergalactic latitude) respectively. Looking down at the XZ plane from Hercules, the positive Y axis points straight towards us and is invisible:

It is to be hoped that a future version of Celestia will display a supergalactic grid. For the time being, the following program draws the supergalactic plane.

--[[
Display the supergalactic coordinates of the direction in which the observer is
facing.  He is initially facing the Virgo Cluster (the center of the Virgo
Supercluster), with the north supergalactic pole in Hercules overhead.  To
change his orientation, press the arrow keys or drag on the sky.
]]
require("std")

--variables used by tickHandler:
local observer = celestia:sane()
local supergalacticFrame =
	celestia:newframe("universal", std.supergalacticRotation)

local function tickHandler()
	--Get the observer's forward vector in universal frame coordinates.
	local forward = observer:getorientation():transform(std.forward)

	--Rewrite observer's forward vector in supergalacticFrame coordinates.
	forward = supergalacticFrame:to(forward)

	--Convert cartesian coordinates to spherical.
	local longlat = forward:getlonglat()

	local s = string.format(
		   "SGL = %.2f%s\n"
		.. "SGB = %.2f%s",
		math.deg(longlat.longitude), std.char.degree,
		math.deg(longlat.latitude), std.char.degree)

	celestia:mmprint(s, 1)
end

local function main()
	observer:setposition(std.position)	--Get away from Sun's glare.
	celestia:hide("grid", "ecliptic")
	celestia:show("galacticgrid")

	--Draw the line of the supergalactic plane
	--by marking the galaxies within 1.5 degrees of it.
	for dso in celestia:dsos() do
		if dso:getinfo().type == "galaxy"
			and dso:name() ~= "Milky Way" then
			local galaxyPosition = dso:getposition()
			galaxyPosition = supergalacticFrame:to(galaxyPosition)
			local longlat = galaxyPosition:getlonglat()
			if math.degmath.deg(math.absmath.abs(longlat.latitude)) < 1.5 then
				dso:markObject:mark("white", "plus", 10)
			end
		end
	end

	--Point the observer at the Virgo Cluster,
	--with north supergalactic pole overhead.
	local forward = celestia:newvectorlonglat(math.rad(104), math.rad(-2))
	local up      = celestia:newvectorlonglat(math.rad(  0), math.rad(90))
	local orientation = celestia:newrotationforwardup(forward, up)
	observer:setorientation(supergalacticFrame:from(orientation))

	celestia:registereventhandler("tick", tickHandler)
	wait()
end

main()
MM SGL = 104.00° SGB = -2.00°

Press . (period) to widen the field of view, and control-b to display the constellation boundaries. Starting at VirgoVirgo and moving left, towards higher supergalactic longitudes, verify that the supergalactic plane goes through the constellation pairs HydraHydra and CentaurusCentaurus, PavoPavo and IndusIndus, and PiscesPisces and PerseusPerseus. We’ll have a better way to draw the supergalactic equator in traceRetrograde.

The Virgo ClusterVirgo Cluster (of galaxies) is the heart of the Virgo Supercluster, and the galaxy M87M87 (galaxy) is the heavyweight of the Virgo Cluster. The direction towards M87 should be the direction towards the center of the supercluster. What are the supergalactic coördinates of M87?

local m87 = celestia:find("M 87") --one space after M (Messier) celestia:select(m87) local m87Position = m87:getposition() --Convert position from universal frame to supergalacticFrame. m87Position = supergalacticFrame:to(m87Position) --Convert cartesian coordinates to spherical. local longlat = m87Position:getlonglat() local s = string.format( "SGL = %.15g%s\n" .. "SGB = %.15g%s", math.deg(longlat.longitude), std.char.degree, math.deg(longlat.latitude), std.char.degree) SGL = 102.880597309482° SGB = -2.34997614192947°

Comet Tailcomet tail Points Away from the Sun

The gas tail of a comet always points away from the Sun, regardless of the comet’s direction of motion. Let’s position the observer to see this.

A Celestia comet follows an elliptical orbit with the Sun at one focus. For example, Halley’s CometHallay’s Comet, direction of tail of has the following orbital elementsorbital elements in the file data/comets.ssccomets.ssc (file).

EllipticalOrbitEllipticalOrbit (in .ssc file) { Epoch 2449400.5 #1994 Feb 17 00:00UT Period 75.31589 SemiMajorAxis 17.834144 Eccentricity 0.967143 Inclination 162.262690 AscendingNode 58.420081 ArgOfPericenter 111.332485 MeanAnomaly 38.384264 }

The epochepoch (orbital element) is the time the snapshot was taken, written as a Julian date (tdb). The periodperiod (orbital element) is in Earth years. The semi-major axissemi-major axis (orbital element) for a comet is in astronomical units (linearDistance).

The eccentricityeccentricity (orbital element) is a dimensionless quantitydimensionless quantity with no unit of measurement. It indicates the shape of the ellipse: 0 for a perfect circle, increasing towards 1 as the ellipse gets longer. The following relationship is satisfied by the eccentricity e and the lengths of the semi-major and semi-minor axessemi-minor axis (of ellipse) a and b. We can easily solve it for any one of a, b, or e in terms of the other two. b2 = a2 (1 − e2) The Sun occupies one of the focifocus (of ellipse) of the ellipse. The distance from the center of the ellipse to a focus is ae. If the ellipse is a circle, this distance is zero. The distance from one end of the ellipse to the nearest focus is aae = a(1 − e). If the ellipse is a circle, this distance is a.

The shape of the ellipse is easy to visualize: its’s given by the numbers a and b. The orientation of the ellipse in space is much harder. Let’s introduce a piece of terminology to deal with it. The axisaxis (of orbit) of an object’s orbit is a vector perpendicular to the ellipse, pointing from the object’s primary towards the direction from which the orbit is counterclockwise. For example, the axis of the Earth’s orbit around the Sun would be the Y axis of the Sun’s ecliptic frame. For an observer perched on the arrowhead of this vector, the Earth would orbit counterclockwise around the Sun.

The orientation of the ellipse is given by the next three orbital elements, which are angles in degrees. First, the inclinationinclination (orbital element) for a comet is the angle between the plane of the ellipse and the XZ plane of the Sun’s ecliptic frame. More precisely, it is the angle between the axis of the ellipse and the Y axis of the Sun’s ecliptic frame.

Halley spends half of its time south of the XZ plane of the Sun’s ecliptic frame, and half north. The point where it crosses the XZ plane on its way north is called the ascending nodeascending node (orbital element); the point on the way south is the descending nodedescending node. The AscendingNode value in data/comets.ssc gives the longitude of this point in the XZ plane, measured counterclockwise (when viewed from the north) from the positive X axis. The AscendingNode value is therefore the size of an angle lying in the XZ plane.

The apoapsisapoapsis (of orbit) is the point in the orbit farthest from the primary body; the periapsisperiapsis (of orbit) is the point closest to the primary. Since Halley’s primary is the Sun, we can also use the terms aphelionaphelion (of orbit) and perihelionperihelion (of orbit). The ArgOfPericenterargument of pericenter (orbital element) value is the longitude of the perihelion in the plane of the orbit, measured counterclockwise from the meridian of the ascending node. The ArgOfPericenter value is therefore the size of an angle lying in the plane of the orbit. Since the perihelion and aphelion are on opposite sides of the orbit, the longitude of the aphelion is argOfPericenter + 180°.

The mean anomaly will be discussed in broadsideEllipse.

The orbital elements are placed in the table at the start of the following program, converted to units that Celx finds congenial. The epoch and orbitPeriod are in days, the length of the axis is in microlightyears, and the angles are in radians.

To demonstrate that the tail points away from the Sun, we will position the observer on the axis of the orbit and look straight down at the plane of the ellipse. Viewing the ellipse broadside, we will see its true shape without any foreshortening.

The ideal frame of reference for this problem would have its origin at the Sun and its XZ plane in the plane of the ellipse. The X axis would point along the major axis of the ellipse towards the perihelion. The Y axis would be the axis of the orbit. It would normally point north, but ours will point south because Halley’s orbital inclination of 162° is greater than 90°. Look at the bright side: the observer will see the familiar constellations of Ursa MajorUrsa Major and MinorUrsa Minor as he looks through the ellipse. As usual, the Z axis is determined by the other two axes (framesOfReference); it would be parallel to the minor axis of the ellipse.

We will create this ideal cometFrame as a rotation of the Sun’s ecliptic frame. Let’s start with a scenario that’s too good to be true. If the ellipse lay in the following orientation in the XZ plane of the Sun’s ecliptic frame, the necessary rotation would be (a.k.a. std.rotation0std.rotation0) and the cometFrame would be the same as the Sun’s ecliptic frame.

But we are not this lucky. The ellipse actually lies at a crazy orientation in space, so we will need a nonzero rotation to move the above ellipse into its real orientation. The following program builds this rotation as the product of three simpler rotations. We saw in quaternionMultiplication that a product can be visualized from left to right or from right to left. For this rotation, we recommend the left-to-right interpretation. Keep in mind that the rotation that positions the ellipse will be the rotation that will position the cometFrame. When we’re done, the major and minor axes of the ellipse will be the X and Z axes of the cometFrame.

1. First, we rotate the above ellipse counterclockwise through an angle of -orbit.argOfPericenter radians or –111° around the Y axis of the Sun’s ecliptic frame. A negative angle is necessary to get the counterclockwise direction; see orientation. But instead of negating the angle, we will negate the Y axis. Our rotations alsways have a nonnegative angle.

2. Then we rotate the ellipse through an angle of -orbit.inclination radians or –162° around the X axis of the Sun’s ecliptic frame. This raises the top part of the ellipse towards positive Y by 162° and lowers the much larger bottom part towards negative Y by the same amount. (Since 162° is greater than 90°, the top part will actually start coming down again and the bottom part will come back up.) Part of the ellipse is now above the XZ plane and part below. Despite the rotation, the ascending and descending nodes remain unmoved on the X axis. In fact, they are the only points of the ellipse that remain unmoved during this rotation.

3. Finally, we apply one more counterclockwise rotation around the Y axis of the Sun’s ecliptic frame, through an angle of -orbit.ascendingNode radians or –58°. The line connecting the ascending and descending nodes remains in the XZ plane, and still goes through the origin, but is now 58° away from the positive X axis.

From our point of view Halley will orbit in the standard direction, counterclockwise. The planets, however, will orbit clockwise, since Halley’s inclination is almost 180°.

--[[
Look down on the plane of Halley's orbit around the Sun as the comet goes
through its perihelion.  Demonstrate that the tail points away from the Sun.

The XZ plane of the cometFrame is the plane of the orbit.  It lies in the plane
of the window.  The Sun, at (0, 0, 0) in the cometFrame, is at the center of the
window.  The X axis of the cometFrame (the major axis of the ellipse) points
towards the top of the window, with the perihelion (a - ae, 0, 0) above the Sun.
Most of the orbit is below the window, out of sight.
The Y axis of the cometFrame points out of the window towards the user.
The Z axis of the cometFrame points towards the right.
The comet orbits counterclockwise from our point of view.  The visible part of
the orbit goes from right to left.
]]
require("std")

local orbit = {			--from data/comets.ssc file
	epoch = 2449400.5,	--1994 Feb 17 00:00UT
	period = 75.31589 * celestia:find("Sol/Earth"):getinfo().orbitPeriod,
	semiMajorAxis = 17.834144 * 1e6 / std.auPerLy,	--microlightyears
	eccentricity = 0.967143,
	inclination = math.rad(162.262690),	--at epoch
	ascendingNode = math.rad(58.420081),	--at epoch
	argOfPericenter = math.rad(111.332485),	--at epoch
	meanAnomaly = math.rad(38.384264)	--at epoch
}

local function main()
	local observer = celestia:sane()
	celestia:hide("constellations", "ecliptic", "grid")  --distracting
	celestia:show("orbits")
	--We know that Halley's Comet is Halley's Comet.
	celestia:hidelabel("comets")
	--Show Halley's orbit when selected, but no other orbits.
	celestia:setorbitflagsCelestia:setorbitflags({Planet = false})

	local comet = celestia:find("Sol/Halley")
	celestia:select(comet)
	local info = comet:getinfo()

	local rotation =
		  celestia:newrotation(-std.yaxis, orbit.argOfPericenter)
		* celestia:newrotation(-std.xaxis, orbit.inclination)
		* celestia:newrotation(-std.yaxis, orbit.ascendingNode)

	local cometFrame = celestia:newframe("ecliptic", info.parent, rotation)
	observer:setframe(cometFrame)

	local a = orbit.semiMajorAxis
	local e = orbit.eccentricity
	local perihelion = a * (1 - e)	--distance in microlightyears from Sun

	--Close enough to see tail, far enough to see shape of ellipse.
	local observerPosition = celestia:newposition(0, 4 * perihelion, 0)

	observer:setposition(cometFrame:from(observerPosition))

	--Look towards the Sun.  The X axis of the cometFrame (which lies along
	--the major axis) points towards the top of the window.
	observer:lookat(
		cometFrame:from(std.position0),
		cometFrame:from(std.xaxis))

	--80 days before the perihelion of 1986
	celestia:settime(celestia:utctotdb(1986, 2, 9) - 80)

	--[[
	One orbit of simulation time (75 years) per hour of real time.
	The interesting part of the program will take about 20 seconds of
	real time.  info.orbitPeriod is in Earth days.
	]]
	celestia:settimescale(24 * info.orbitPeriod)
	wait()
end

main()

Pause the above program with the space bar. Then use the straight edge of a piece of paper to verify that the tail points away from the Sun. Better yet, use OpenGL (equalArea) to draw a straight line from the Sun to the nucleus of the comet.

An Equatorial Frameequatorial frame

Each celestial object has its own equatorial frame of reference. The object is called the reference object of the frame. The origin of the frame is the center of the reference object. The XZ plane is the plane of the object’s equator, with the X axis pointing towards the object’s vernal equinox. The X axis therefore also lies in the plane of the object’s orbit around its primary. The Y axis points along the axis of the reference object, north for most objects and south for Venus and the other retrograde rotators. The Z axis is determined by the other two axes because all Celestia frames are right-handedright-handed frame; see framesOfReference.

At noon UTC on January 1, 2000, the axes of the Earth’s equatorial frame were parallel to those of the J2000 equatorial coördinates in j2000Equatorial. The X axis of the Earth’s equatorial frame pointed towards RA 0h Dec 0°, the vernal equinox in Pisces. The Y axis pointed towards the north celestial pole near Polaris. But since that moment of alignment, the Earth’s equatorial frame has been gradually tilting as the Earth precessesprecession of Earth’s axis (wobbles on its axis).

In eclipticFrame, we looked along the X axis of the Earth’s ecliptic frame. Let us now look along the X axis of the equatorial frame. We will see the Earth in front of the vernal equinox in Pisces.

--[[
Demonstrate that the X axis of the Earth's equatorial frame currently points
towards Pisces, the Y axis towards Polaris.
Position the observer on the negative X axis and look towards the origin,
with the Y axis pointing up.
]]
require("std")

local function main()
	local observer = celestia:sane()
	local earth = celestia:find("Sol/Earth")

	local equatorialFrame = celestia:newframe("equatorial", earth)
	observer:setframe(equatorialFrame)

	local microlightyears = 12 * earth:radius() / KM_PER_MICROLY
	local position = celestia:newposition(-microlightyears, 0, 0)
	observer:setposition(equatorialFrame:from(position))

	observer:lookat(
		equatorialFrame:from(std.position0),
		equatorialFrame:from(std.yaxis))
	wait()
end

main()

Change the above program to sight along the Y axis of the Earth’s equatorial frame. You will have to select a different up vector; a reasonable choice would be equatorialFrame:from(std.zaxis). A dazzling Antarctica will present itself, with the Earth blocking our view of the north celestial pole.

Now use Venus instead of the Earth. The Universe behind the planet should be upside down, with Venus blocking a point near the south celestial pole.

Sight along the Y axis of the Earth’s equatorial frame again, facing Antarctica and Polaris. View the Earth from 25 radii away, so it doesn’t block as much of the sky. Turn on the ecliptic grid.

celestia:hide("grid") --centered at Polaris celestia:show("eclipticgrid") --centered at Draco

Now speed up time by a factor of ten billion = 1 · 1010. Press l (lowercase L) ten times, or call

celestia:settimescale(1e10) --means 10000000000

Observe that the north celestial pole, as marked by the Earth, circles the north pole of the ecliptic in Draco. This gradual change in the direction of the Earth’s axis is called precession.

Select the star ThubanThuban (star) (α Draconis) and start again at the present. This time go backwards with a negative timescale, or by pressing j before the lowercase L’s. When did the Earth’s equatorial frame Y axis point closest to this star?

Start again at the present. Sight along the X axis of the Earth’s equatorial frame towards Pisces, with the Y axis of the Earth’s equatorial frame pointing up. Set the timescale to 10 billion. The target towards which the X axis is pointing will move from left to right along the ecliptic, passing through the 12 constellations of the zodiaczodiac in reverse order. The X axis will spend an entire age in each constellation because the precession is so slow. We have just about completed our traversal of PiscesPisces, Age of, so this is the dawning of the Age of AquariusAquarius, Age of. How long will it take for the X axis to go all the way around the ecliptic?

List the Retrograde Rotators

Let’s find the current tilt (or obliquityobliquity of Earth’s axis) of the Earth’s axis with respect to the axis of the ecliptic. The two vectors in the following fragment are unit vectors in universal coördinates. toDraco points north along the axis of the ecliptic, towards Draco. toPolaris points north along the axis of the Earth, towards Polaris.

local earth = celestia:find("Sol/Earth") local equatorialFrame = celestia:newframe("equatorial", earth) local toDraco = std.yaxis local toPolaris = equatorialFrame:from(std.yaxis) local theta = toPolaris:angle(toDraco) local s = string.format("%.15g%s", math.deg(theta), std.char.degree) --simpler way to approximate the same number local s = string.format("%.15g%s", math.deg(std.tilt), std.char.degree) 23.439278770379° 23.4392911°

Similarly, we can show that the tilt of Venus’s axis is 178.761°. Since the tilt is greater than 90°, we say that the planet’s rotation is retrograderetrograde rotation. Let’s find all the retrograde rotators in the Solar System. For the iterator Object:family and the string:sub method, see Recursion

--[[
List the solar system objects with retrograde rotation.  These are the objects
whose equatorial frame Y axis is more than 90 degrees from the universal frame Y
axis.
]]
require("std")

local function main()
	--Keep the Sun in view, but get away from its glare.
	local observer = celestia:sane()
	observer:setposition(std.position)
	local s = ""

	for level, object in celestia:find("Sol"):familyObject:family() do
		local equatorialFrame = celestia:newframe("equatorial", object)
		local rotationAxis = equatorialFrame:from(std.yaxis)
		local theta = math.deg(std.yaxis:angle(rotationAxis))

		if theta > 90 then
			s = s .. string.format(
				"%s/%s %g%s\n",
				object:getinfo().parent:name(), object:name(),
				theta, std.char.degree)
		end
	end

	celestia:print(s:substring.sub(1, 2^10 - 1), 60, -1, 1, 1, -1)
	wait()
end

main()
Sol/Venus 178.761° Sol/Uranus 97.722° Uranus/Miranda 95.1698° etc. Neptune/Triton 130.915° Sol/Pluto 115.6° etc. Sol/2 Pallas 113° etc.

The above program assumed that the plane of each body’s orbit is the XZ plane of the universal frame of reference. In reality, each body has its own orbital plane. We can get more accurate angles by using the –Z axis of each body’s “chase frame” (chaseFrame), which is exactly perpendicular to the body’s orbital plane.

local equatorialFrame = celestia:newframe("equatorial", object) local chaseFrame = celestia:newframe("chase", object) --vectors in universal frame local rotationAxis = equatorialFrame:from(std.yaxis) local orbitAxis = chaseFrame:from(-std.zaxis) local theta = math.deg(orbitAxis:angle(rotationAxis)) Sol/Venus 177.362° Sol/Uranus 97.7709° etc.

Trace the Analemmaanalemma and the Equation of Time

The Earth goes around the Sun at varying speeds, fastest at perihelion in January and slowest at aphelion in July. This causes the Sun as seen from the Earth to move along the ecliptic at varying speeds, fastest in January and slowest in July. And even if the Sun moved steadily, it would still change its right ascension at varying speeds because of the tilt of the ecliptic: fastest at the solstices and slowest at the equinoxes. (Imagine a flight from New York to London along a great circle. The plane would cross the meridians of longitude most frequently during the northernmost part of the flight.) These two effects cancel out on approximately June 14th of every year.

We can gauge the Sun’s uneven progress along the ecliptic by comparing it with an imaginary mean Sunmean Sun. This fictitious body moves sedately along the celestial equator, increasing its right ascension at the constant rate of 360° per year. The following program keeps the observer on the Earth’s sub-mean solar pointsub-mean solar point, the point on the Earth’s surface where the mean Sun is directly overhead. This point is always on the Earth’s equator.

The observer looks straight up into the sky towards the mean Sun, keeping it at the center of the window. Polaris is off the top of the window. The real Sun appears to the left (east) or right (west) of the mean Sun, depending on whether the real Sun is ahead of or behind the mean Sun in right ascension. The real Sun also appears above (north) or below (south) the mean Sun, depending on the season of the year. These changing offsets with respect to the mean Sun combine to make the real Sun trace a figure eight called the analemma.

The horizontal component of the analemma indicates how fast or slow a sundial would be on each day of the year. When the real Sun is slow in its journey along the ecliptic (i.e., to the right [west] of the mean Sun), the sundial is fast because the real Sun arrives earlier than the mean Sun at any given position in their east-to-west journey across the daytime sky. When the real sun is fast, the sundial is slow.

The ideal frame of reference for this problem would have its origin at the center of the Earth and its XZ plane in the plane of the equator of the Earth. The X axis would point towards the mean Sun, not the real Sun, and the Y axis towards Polaris. We will create this meanSolFrame with a rotation that rotates the toMeanSol vector at the constant rate of 360° per year. This vector will be the X axis of the meanSolFrame.

For now, we will just watch the real Sun zigzag around. We will draw the figure eight of the analemma on the window using the OpenGL graphics in Corkscrew, and we will create the analemma without a tick handler in scriptedRotation.

--[[
Move the Sun along the figure eight of the analemma,
as seen from the sub-mean solar point on the Earth.
]]
require("std")

--variables used by tickHandler:
local observer = celestia:sane()
local sol = celestia:find("Sol")
local earth = celestia:find("Sol/Earth")
local equatorialFrame = celestia:newframe("equatorial", earth)

local info = earth:getinfo()
local orbitPeriod = info.orbitPeriod
local microlightyears =
	(earth:radius() + info.atmosphereHeight) / KM_PER_MICROLY
local position = celestia:newposition(microlightyears, 0, 0)

--Assume that the Sun and the man Sun agree in right ascension on the starting
--date (June 14), i.e., assume that the value of the equation of time is 0 then.

local year = celestia:tdbtoutc(celestia:gettime()).year	--current year
local t0 = celestia:utctotdb(year, 6, 14)
local toSol0 = equatorialFrame:to(sol:getposition(t0), t0):tovector()
local toMeanSol0 = std.yaxis:erect(toSol0):normalize()

local function tickHandler()
	local t = celestia:gettime()
	local years = (t - t0) / orbitPeriod	--years since t0
	local rotation = celestia:newrotation(std.yaxis, -2 * math.pi * years)
	local toMeanSol = rotation:transform(toMeanSol0)

	local right = toMeanSol	--X axis of meanSolFrame points towards mean Sun
	local up = std.yaxis	--Y axis of meanSolFrame points to Polaris
	local forward = up ^ right

	local meanSolFrame = celestia:newframe("equatorial", earth,
		celestia:newrotationforwardup(forward, up))

	observer:setframe(meanSolFrame)
	observer:setposition(meanSolFrame:from(position))
	observer:lookat(
		meanSolFrame:from(2 * position),   --Look away from the origin.
		meanSolFrame:from(std.yaxis))

	--359 degrees should be treated as -1 degree.
	local solPosition = meanSolFrame:to(sol:getposition())
	local degreesOfArc = math.deg(solPosition:getlonglat().longitude)
	if degreesOfArc >= 180 then
		degreesOfArc = degreesOfArc - 360
	end

	local speed = ""
	if degreesOfArc > 0 then
		speed = std.char.larr .. " Sun fast, sundial slow"
	elseif degreesOfArc < 0 then
		speed = "Sun slow, sundial fast " .. std.char.rarr
	end

	local minutesOfTime = math.absmath.abs(degreesOfArc) * 24 * 60 / 360
	local minutes = math.floormath.floor(minutesOfTime)         --integer part
	local seconds = 60 * math.fmodmath.fmod(minutesOfTime, 1)  --fractional part

	local n = 10	--how many MMs to print above and below center
	local mm = "MM\n"
	local utc = celestia:tdbtoutc(t)

	local s = string.format(
		"%s"
		.. "MM %s %d, %d\n"
		.. "MM %s\n"
		.. "MM %d minutes, %d seconds\n"
		.. "%s",
		mm:repstring.rep(n - 1),
		std.monthName[utc.month], utc.day, utc.year,
		speed, minutes, std.round(seconds),
		mm:rep(n - 1))

	celestia:print(s, 1, 0, 0, -1, n)
end

local function main()
	celestia:select(sol)
	--tall enough to see top and bottom of analemma, plus 1-degree margin
	observer:setfov(2 * (std.tilt + math.rad(1)))

	celestia:settime(t0)
	--Ten days of simulation time per second of real time.
	celestia:settimescale(60 * 60 * 24 * 10)

	celestia:registereventhandler("tick", tickHandler)
	wait()
end

main()
MM June 14, 2013 MM MM 0 minutes, 0 seconds

Does MarsMars, analemma of have an analemma? Is it a figure eight?

The equation of timeequation of time is a function giving the horizontal component of the analemma for a given date. Its value is positive when the real Sun is to the right of the mean Sun; the real Sun is then slow and the sundial is fast. Its value is negative when the real Sun is to the left of the mean Sun; the real Sun is then fast and the sundial is slow.

Let’s plot the graph of the equation for the current year. We assume that the value of the function is zero at midnight on June 14. Don’t forget the LuaHook parameter in celestia.cfg (luaHookFunctions).

--[[
This file is luahook.celx.
Draw the equation of time for the current year.
January 1 is at the left edge of the window, December 31 is at the right.
The top edge is when a sundial is 18 minutes fast, bottom edge 18 minutes slow.
]]

--variables used by equationOfTime:
local sol = nil   --Celestia can't call celestia:find at this early time.
local earth = nil
local orbitPeriod = nil
local equatorialFrame = nil

--Return a positive number if a sundial is fast on date t, negative if slow.
--Return value is in days, not minutes.

local function equationOfTime(t)
	assert(type(t) == "number")
	if sol == nil then
		sol = celestia:find("Sol")
		earth = celestia:find("Sol/Earth")
		orbitPeriod = earth:getinfo().orbitPeriod
		equatorialFrame = celestia:newframe("equatorial", earth)
	end

	local year = celestia:tdbtoutc(t).year	--current year
	local t0 = celestia:utctotdb(year, 6, 14)
	local toSol0 = equatorialFrame:to(sol:getposition(t0), t0):tovector()
	local toMeanSol0 = std.yaxis:erect(toSol0):normalize()

	local years = (t - t0) / orbitPeriod	--years since t0
	local rotation = celestia:newrotation(std.yaxis, -2 * math.pi * years)
	local toMeanSol = rotation:transform(toMeanSol0)

	local meanSolFrame = celestia:newframe("equatorial", earth,
		celestia:newrotationforwardup(std.yaxis ^ toMeanSol, std.yaxis))
	local solPosition = meanSolFrame:to(sol:getposition(t), t)
	local theta = solPosition:getlonglat().longitude
	if theta >= math.pi then
		theta = theta - 2 * math.pi
	end

	--theta is how fast the real Sun is.  Convert radians to days.
	--Negate it to return how fast the sundial is.
	return -theta / (2 * math.pi)
end

celestia:setluahook {
	year = nil,
	lengthOfYear = nil,

	renderoverlay = function(self)
		if package.loaded.std == nil then  --standard lib not loaded yet
			require("std")
			self.year = celestia:tdbtoutc(celestia:gettime()).year

			self.lengthOfYear =
				celestia:utctotdb(self.year + 1, 1, 1)
				- celestia:utctotdb(self.year, 1, 1)
		end

		gl.MatrixMode(gl.PROJECTION)
		gl.PushMatrix()
		gl.LoadIdentity()

		local width, height = celestia:getscreendimension()
		glu.Ortho2D(0, width, 0, height)

		gl.MatrixMode(gl.MODELVIEW)
		gl.PushMatrix()
		gl.LoadIdentity()

		--origin at center of left edge of window
		gl.Translate(0, height / 2)

		gl.Enable(gl.BLEND)
		gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
		gl.Disable(gl.LIGHTING)
		gl.Disable(gl.TEXTURE_2D)	--disabled for graphics

		if celestia:getrenderflags().smoothlines then
			gl.Enable(gl.LINE_SMOOTH)	--antialiasing
			gl.LineWidth(1.5)
		end

		gl.Color(1, 1, 1, 1)	--rgba

		gl.Begin(gl.LINE_STRIPgl.LINE_STRIP)
		--left to right across the pixels of the window
		for x = 0, width - 1 do
			local t = celestia:utctotdb(self.year, 1, 1)
				+ self.lengthOfYear * x / (width - 1)

			local minutesOfTime = equationOfTime(t) * 24 * 60
			--sundial fast or slow by at most 16 or 17 minutes
			local y = (height / 2) * (minutesOfTime / 18)
			gl.Vertex(x, y)
		end
		gl.End()

		gl.MatrixMode(gl.PROJECTION)
		gl.PopMatrix()

		gl.MatrixMode(gl.MODELVIEW)
		gl.PopMatrix()
	end
}
--[[
Provide a black background for the graph of the equation of time,
with a horizontal red X axis.
]]
require("std")

local function main()
	celestia:sane()

	--Render only the ecliptic.  It will be the X axis of the graph.
	local flags = celestia:getrenderflags()
	for key, value in pairs(flags) do
		flags[key] = key ==== (equals operator) "ecliptic"	--true if key equals "ecliptic"
	end
	celestia:setrenderflags(flags)

	flags = celestia:getlabelflags()
	for key, value in pairs(flags) do
		flags[key] = false
	end
	celestia:setlabelflags(flags)

	wait()
end

main()

Add bells and whistles: margins on the left and right with glu.Ortho2Dglu.Ortho2D, X and Y axes with labeled ticks, and a graph paper background in a subdued color.

Use the std.zero function (findZero) to find the zeroes, maxima, and minima, and label them with ticks. Can you plot the separate contributions to the equation of time of the eccentricity of the Earth’s orbit and the tilt of the Earth’s axis? Does Mars have an equation of time?

Broadside View of an Elliptical Orbit

Let’s watch a satellite going around its orbit. We’ll pick NeptuneNeptune’s moon NereidNereid (moon of Neptune), whose plain vanilla EllipticalOrbit has an eccentricity large enough for a well-proportioned ellipse with attractive variations in speed. The following are Nereid’s orbital elements in the file data/solarsys.sscsolarsys.ssc (file).

EllipticalOrbit { Epoch 2447763.5 #1989 Aug 25 00:00UT (Voyager encounter) Period 360.13619 #mean SemiMajorAxis 5513400 #mean Eccentricity 0.7512 #mean Inclination 28.385 #at epoch AscendingNode 190.678 #at epoch ArgOfPericenter 17.690 #at epoch MeanAnomaly 36.056 #at epoch }

The orbital elements are placed in the table at the start of the following program, converted to units that Celx finds congenial. Since Nereid is a moon, not a comet, the period is in days and the angles are measured with respect to the equatorial frame of the primary. For the mean anomaly, see the exercises below. For the other values, and their rôles in creating the new frame, see tailPoints.

The ellipse of Nereid’s orbit will lie flat in the plane of the window, with the major axis horizontal because the window is landscape. We will look down at Nereid’s orbit from above, peering through the ellipse towards the south pole of the ecliptic in Dorado. The orbit will also lasso other southern landmarks such as the Large Magellanic CloudLarge Magellanic Cloud and CanopusCanopus (star). The observer will be positioned on the line perpendicular to, and going through the center of, the ellipse. He will view the orbit broadside, seeing its true shape with no foreshortening.

In tailPoints we created a cometFrame as a rotation of the Suns’s ecliptic frame. Here we will create a nereidFrame as a rotation of Neptune’s equatorial frame. The origin of the nereidFrame will be the center of Neptune, and its XZ plane will be the plane of Nereid’s orbit. The X axis will point along the major axis of the ellipse towards the periapsis. The Y axis will be perpendicular to the plane of the orbit, pointing north since Nereid’s orbit is not retrograde. As usual, the Z axis will be determined by the other two axes (framesOfReference); it will be parallel to the minor axis of the ellipse.

Neptune will be at (0, 0, 0) in the nereidFrame. The center of the ellipse will be at (–ae, 0, 0), where a is the length of the semi-major axis and e is the eccentricity; see tailPoints for a diagram. The periapsis will be (aae, 0, 0), the apoapsis will be (–aae, 0, 0), and the observer will be at (–ae, 3a, 0). The center of the ellipse will be at the center of the window. The X axis will point right, the Z axis will point down, and the Y axis will point out of the screen towards the observer.

--[[
Display an overhead view of Nereid's orbit around Neptune.  The observer is on
a line perpendicular to, and going through the center of, the ellipse.
Neptune is at the right focus, with the periapsis to the right of it.
]]
require("std")

local orbit = {			--from data/solarsys.ssc file
	epoch = 2447763.5,	--1989 Aug 25 00:00UT (VoyagerVoyager 2 (spacecraft) encounter)
	period = 360.13619,	--mean, in days
	semiMajorAxis = 5513400 / KM_PER_MICROLY,	--mean
	eccentricity = 0.7512,			--mean
	inclination = math.rad(28.385),		--at epoch
	ascendingNode = math.rad(190.678),	--at epoch
	argOfPericenter = math.rad(17.690),	--at epoch
	meanAnomaly = math.rad(36.056)		--at epoch
}

local function main()
	local observer = celestia:sane()
	celestia:hide("constellations", "ecliptic", "grid")  --distracting lines
	celestia:show("orbits")

	--Show the orbits of only Nereid and TritonTriton (moon of Neptune).
	celestia:setorbitflagsCelestia:setorbitflags({Planet = false})

	local nereid = celestia:find("Sol/Neptune/Nereid")
	celestia:select(nereid)
	local info = nereid:getinfo()

	local rotation =
		  celestia:newrotation(-std.yaxis, orbit.argOfPericenter)
		* celestia:newrotation(-std.xaxis, orbit.inclination)
		* celestia:newrotation(-std.yaxis, orbit.ascendingNode)

	local nereidFrame =
		celestia:newframe("equatorial", info.parent, rotation)
	observer:setframe(nereidFrame)

	local a = orbit.semiMajorAxis
	local e = orbit.eccentricity
	local centerOfEllipse  = celestia:newposition(-a * e, 0, 0)
	local observerPosition = celestia:newposition(-a * e, 3 * a, 0)
	observer:setposition(nereidFrame:from(observerPosition))

	--major axis horizontal, minor axis vertical for landscape window
	local up = nereidFrame:from(-std.zaxis)
	observer:lookat(nereidFrame:from(centerOfEllipse), up)

	celestia:settime(orbit.epoch)
	--one simulation orbit per 30 seconds of real time
	celestia:settimescale(60 * 60 * 24 * info.orbitPeriod / 30)
	wait()
end

main()

In equalArea we will overlay the picture with line segments illustrating Kepler’s Equal Area Law.

The mean anomalymean anomaly (orbital element) is the fraction of the orbital period that has elapsed since the satellite was most recently at its periapsis. Strangely, it is usually expressed as an angle of 0 to almost 360° even though it is a fraction of an interval of time, not a fraction of a circle. Since we are programming in Celx, we will write it as 0 to almost 2π radians. The mean anomaly in the above table is measured in radians at the time of the epoch.

Let’s use the mean anomaly to display Nereid at the periapsis before the epoch. At periapsis, Nereid is on the positive X axis of the nereidFrame, which points to the observer’s right. Change the above Celestia:settime and Celestia:settimescale to the following. The expression orbit.meanAnomaly / (2 * math.pi) is a fraction in the range 0 to almost 1. It is the fraction of the orbital period that elapsed between the periapsis before the epoch and the epoch.

--Freeze the simulation at the time below. celestia:settimescale(0) --[[ Julian date of the periapsis before the epoch (or the epoch itself, if that's when the periapsis was). At periapsis, Nereid will be on the positive X axis of the nereidFrame. ]] local t0 = orbit.epoch - orbit.period * orbit.meanAnomaly / (2 * math.pi) celestia:settime(t0) local utc = celestia:tdbtoutc(t0) local s = string.format( "Nereid periapsis at julian date %.15g\n" .. "%s %d, %d %02d:%02d:%.15g UTC\n\n", t0, std.monthName[utc.month], utc.day, utc.year, utc.hour, utc.minute, utc.seconds) utc = celestia:tdbtoutc(orbit.epoch) s = s .. string.format( "Epoch at julian date %.15g\n" .. "%s %d, %d %02d:%02d:%.15g UTC", orbit.epoch, std.monthName[utc.month], utc.day, utc.year, utc.hour, utc.minute, utc.seconds) celestia:print(s, 60) wait() Nereid periapsis at julian date 2447727.43035981 July 19, 1989 22:18:46.9043952226639 UTC Epoch at julian date 2447763.5 August 24, 1989 23:59:3.81724298000336 UTC

Let’s compute the mean anomaly at the epoch ourselves, instead of reading it from data/solarsys.ssc. This will be more than a foray into Keplerian Astronomy—it will be a reconnaissance-in-force requiring new terminology. The radius vector is the line connecting a primary with its satellite. And the eccentric anomalyeccentric anomaly is a convenient intermediate result, from which we will eventually compute Nereid’s current position. Here we will demonstrate its usefulness by computing the mean anomaly at the epoch.

The following relationship is satisfied by the length of the radius vector r, the semi-major axis a, the eccentricity e, and the eccentric anomaly E. r = a(1 − e cos E) We can solve this equation for cos E, cos E = 1 − r/a e and then for E: E = arccosarccosine (trig function) 1 − r/a e The mean anomaly M is then given by Kepler’s equationKepler’s Equation M = Ee sin E

celestia:settime(orbit.epoch) local r = nereidFrame:to(satellite:getposition()):tovector():length() local E = math.acosmath.acos((1 - r / orbit.semiMajorAxis) / orbit.eccentricity) local M = E - orbit.eccentricity * math.sin(E) --Kepler's equation local s = string.format("Mean anomaly at epoch: %g%s", math.deg(M), std.char.degree)

The output agrees with the value 36.056° in data/solarsys.ssc.

Mean anomaly at epoch: 36.056°

Now let’s compute the x, y, z position of Nereid at any given time. Of course, Celestia already does this for us. But we will demonstrate that we can do it ourselves using the orbital elements.

If we had the current eccentric anomaly E, we could easily compute the polar coördinates (r, θ) of the satellite in the nereidFrame XZ plane. In fact, we’ve already seen how to compute r, r = a(1 − e cos E) and there’s another formula for θ. We could then convert the polar coördinates to Cartesian coördinates in the XZ plane, and finally to Cartesian coördinates in the universal frame.

The problem is getting the eccentric anomaly. We can start by computing the mean anomaly M from the current time t. M = 2π tepoch period And from Kepler’s equation we know that E = M + e sin E There is no simple way, however, to solve Kepler’s equation for E. The application MathematicaMathematica (application) commented dryly that “The equations appear to involve the variables to be solved for in an essentially non-algebraic way.”

But we don’t have to solve for E. We can just compute a series E0, E1, E2, … of increasingly accurate approximations to E. Our first approximation E0 can simply be M because the eccentricity and the sine are both is fairly small (less than or equal to 1 in absolute value).

E0 = M
E1 = M + e sin E0
E2 = M + e sin E1
E3 = M + e sin E2
etc.

--[[
Display an overhead view of Nereid's orbit around Neptune.  The observer is on
a line perpendicular to, and going through the center of, the ellipse.
Neptune is at the right focus, with the periapsis to the right of it.
Compute the current position of Nereid from its orbital elements,
and compare it to the position computed by Celestia.
]]
require("std")

local orbit = {
	epoch = 2447763.5,	--1989 Aug 25 00:00UT (Voyager encounter)
	period = 360.13619,	--mean, in days
	semiMajorAxis = 5513400 / KM_PER_MICROLY,	--mean
	eccentricity = 0.7512,			--mean
	inclination = math.rad(28.385),		--at epoch
	ascendingNode = math.rad(190.678),	--at epoch
	argOfPericenter = math.rad(17.690),	--at epoch
	meanAnomaly = math.rad(36.056)		--at epoch
}

--variables used by tickHandler:
--time of periapsis before epoch (or epoch itself if that was the periapsis)
local t0 = orbit.epoch - orbit.period * orbit.meanAnomaly / (2 * math.pi)
local a = orbit.semiMajorAxis
local e = orbit.eccentricity
assert(e ~= 1)	--so the tickHandler can divide by 1-e

local nereid = celestia:find("Sol/Neptune/Nereid")
local name = nereid:name()
local parent = nereid:getinfo().parent
local equatorialFrame = celestia:newframe("equatorial", parent)

local nereidFrame = celestia:newframe("equatorial", parent,
	  celestia:newrotation(-std.yaxis, orbit.argOfPericenter)
	* celestia:newrotation(-std.xaxis, orbit.inclination)
	* celestia:newrotation(-std.yaxis, orbit.ascendingNode)
)

local function tickHandler()
	local position = nereid:getposition()
	local s = string.format("%s's position computed by Celestia:\n"
		.. "Universal frame: (%g, %g, %g)\n",
		name, position:getxyz())

	s = s .. string.format(
		"%s equatorial frame: (%g, %g, %g)\n",
		parent:name(), equatorialFrame:to(position):getxyz())

	local p = nereidFrame:to(position)
	s = s .. string.format("nereidFrame: (%g, %g, %g)\n", p:getxyz())

	local longlat = p:getlonglat()
	s = s .. string.format(
		"Polar co%srdinates in nereidFrame XZ plane: (%g, %g%s)\n\n",
		std.char.ouml,
		longlat.distance,
		math.deg(longlat.longitude),
		std.char.degree)

	--Current mean anomaly.
	local M = 2 * math.pi * (celestia:gettime() - t0) / orbit.period

	--Approximate the current eccentric anomaly.
	local E = M
	for i = 1, 10 do
		E = M + e * math.sin(E)
	end

	--Polar coordinates (r, theta) of nereid in nereidFrame XZ plane.
	--r is the length of the radius vector.
	--theta is called the "true anomaly".
	local r = a * (1 - e * math.cos(E))
	local theta = 2 * math.atanmath.atan(
		math.sqrt((1 + e) / (1 - e)) * math.tanmath.tan(E / 2))

	--[[
	Cartesian coordinates of Nereid in nereidFrame in microlightyears.
	In the customary XY plane, the Y axis points up.
	In the XZ plane, the Z axis points down.
	That's why we have to negate the z coordinate.
	]]
	local nereidFrameComputed = celestia:newposition(
		r * math.cos(theta),
		0,
		-r * math.sin(theta))

	local universalFrameComputed = nereidFrame:from(nereidFrameComputed)
	local equatorialFrameComputed =
		equatorialFrame:to(universalFrameComputed)

	s = s .. string.format("%s's position computed by Celx program:\n"
		.. "Universal frame: (%g, %g, %g)\n",
		name, universalFrameComputed:getxyz())

	s = s .. string.format(
		"%s equatorial frame: (%g, %g, %g)\n",
		parent:name(), equatorialFrameComputed:getxyz())

	s = s .. string.format("nereidFrame: (%g, %g, %g)\n",
		nereidFrameComputed:getxyz())

	s = s .. string.format(
		"Polar co%srdinates in nereidFrame XZ plane: (%g, %g%s)\n\n",
			std.char.ouml, r, math.deg(theta), std.char.degree)

	local kilometers = position:distanceto(universalFrameComputed)
	s = s .. string.format("Error: %g kilometers = %g microlightyears",
		kilometers, kilometers / KM_PER_MICROLY)

	celestia:print(s, 1, -1, 1, 1, -6)
end

local function main()
	local observer = celestia:sane()
	celestia:hide("constellations", "ecliptic", "grid")  --distracting lines
	celestia:show("orbits")
	celestia:select(nereid)
	celestia:setorbitflags({Planet = false})

	local centerOfEllipse  = celestia:newposition(-a * e, 0, 0)
	local observerPosition = celestia:newposition(-a * e, 3 * a, 0)

	observer:setframe(nereidFrame)
	observer:setposition(nereidFrame:from(observerPosition))
	--major axis horizontal, minor axis vertical for landscape window
	local up = nereidFrame:from(-std.zaxis)
	observer:lookat(nereidFrame:from(centerOfEllipse), up)

	celestia:settime(t0)
	celestia:registereventhandler("tick", tickHandler)
	wait()
end

main()

Press j, k, l to control the timescale, and space bar to pause the program. At the periapsis before the epoch, Nereid was on the nereidFrame X axis:

Nereid's position computed by Celestia: Universal frame: (93.0734, 7.49089, 468.675) Neptune equatorial frame: (-0.128562, 0.0209451, 0.0636854) nereidFrame: (0.144992, -6.59195e-17, 2.8854e-12) Polar coördinates in nereidFrame XZ plane: (0.144992, -1.14021e-09°) Nereid's position computed by Celx program: Universal frame: (93.0734, 7.49089, 468.675) Neptune equatorial frame: (-0.128562, 0.0209451, 0.0636854) nereidFrame: (0.144992, 0, 0) Polar coördinates in nereidFrame XZ plane: (0.144992, 0°) Error: 2.7298e-05 kilometers = 2.8854e-12 microlightyears

Keep track of the maximum error as Nereid goes all the way around its orbit. Can we make the error smaller by using a more accurate estimate of the eccentric anomaly? One way to do this would be to increase the for loop to 20 iterations. If the loop now takes too much time, use a smarter algorithm that converges more rapidly:

--faster than the simple algorithm --for elliptical orbits whose eccentricity > .3 for i = 1, 6 do E = E + (M + e * math.sin(E) - E) / (1 - e * math.cos(E)) end

What effect does this have on the maximum error?

The alternative algorithms implemented by the for loop are taken from the source code of Celestia. Read them there. The arithmetic is written in the structures SolveKeplerFunc1 and SolveKeplerFunc2 in version/src/celengine/orbit.cpp. The for loop that they share is written once and for all in the template function solve_iteration_fixed in version/src/celmath/solve.h.

Look at other orbits of handsome eccentricity, e.g., that of Sol/Saturn/Bestla. For an extreme eccentricity of 0.967143, try Halley’s Comet. Its Period in data/comets.ssc is in Earth years; multiply it by celestia:find("Sol/Earth"):getinfo().orbitPeriod to convert it to days. The SemiMajorAxis in data/comets.ssc is in astronomical units; multiply it by 1e6 / std.auPerLy to convert it to microlightyears. The angles are in the universal frame. This means that the rotated frame should be a rotation of the Sun’s ecliptic frame, not the Sun’s equatorial frame.

The annual Perseid meteor showerPerseid meteor shower is composed of debris that follow the orbit of comet Swift-TuttleSwift-Tuttle (comet). Let’s verify that the Earth crosses the plane of the comet’s orbit each year on August 12th. Add the comet to the data/comets.ssc file and restart Celestia.

#Orbital elements from Jet Propulsion Laboratory Small-Body Database #http://ssd.jpl.nasa.gov/sbdb.cgi?sstr=109P "Swift-Tuttle:109P Swift-Tuttle" "Sol" { Class "comet" InfoURL "http://en.wikipedia.org/wiki/Comet_Swift%E2%80%93Tuttle" EllipticalOrbit { Epoch 2448968.499784556297 #1992-Dec-11.99978456 Period 133.28 SemiMajorAxis 26.0920694978266 Eccentricity 0.963225755046038 Inclination 113.453816997171 AscendingNode 139.3811920815948 ArgOfPericenter 152.9821676305871 MeanAnomaly 7.631696167124212 } }

In the main function, show the orbits of the planets and the comet. See renderFlags.

local satellite = celestia:find("Sol/Swift-Tuttle") satellite:setorbitvisibility("always")

Change the name of the rotated frame from nereidFrame to swiftFrame. The comet’s orbit lies in the XZ plane of the swiftFrame. Give the observer an edge-on view of the orbit.

local microlightyears = 4 * 1e6 / std.auPerLy --4 astronomical units local observerPosition = celestia:newposition(0, 0, microlightyears) observer:setposition(swiftFrame:from(observerPosition)) local up = swiftFrame:from(std.yaxis) observer:lookat(swiftFrame:from(std.position0), up) --Look at the Sun.

Now set the time to August 12th of the current year.

local year = celestia:tdbtoutc(celestia:gettime()).year celestia:settime(celestia:utctotdb(year, 8, 12)) celestia:settimescale(1)

Is the Earth passing through the plane of the comet’s orbit? Is there any danger?

A Bodyfixed Framebodyfixed frame of reference

Each celestial object has its own bodyfixed frame. (The old name planetographic is now deprecated.) The object is called the reference object of the frame. The origin of the frame is the center of the reference object and the XZ plane is the plane of the object’s equator. The X axis pierces the object’s surface at latitude 0° North, longitude 0° East. For the Earth, this point is in the Gulf of Guinea off the west coast of Africa. The Z axis pierces the surface at latitude 0° North, longitude 90° West. For the Earth, this is in the Galápagos Islands off the west coast of South America. The Y axis pierces the surface at the north pole of the object.

For retrograde rotators such as Venus, the Y axis pierces the surface at the south pole of the object. To keep the axes right-handed (framesOfReference) in this case, the Z axis has to pierce the surface at latitude 0° North, longitude 90° East.

Looking down at the Earth’s bodyfixed XZ plane from the north pole, the Y axis points straight towards us and is invisible:

In eclipticFrame, we looked along the X axis of the Earth’s ecliptic frame. In equatorialFrame, we looked along the X axis of the Earth’s equatorial frame. Now let’s look along the X axis of the Earth’s bodyfixed frame, with the Y axis pointing up. The bodyfixed axes are displayed as colored arrows by the following program. The red “X” arrow does indeed point along the Earth’s bodyfixed X axis. But the blue “Z” arrow points along the positive Y axis, and the green “Y” arrow points along the negative Z axis.

--[[
Position the observer on the negative X axis of the Earth's bodyfixed frame,
looking towards the origin with the Y axis pointing up.
Keep him in geostationary orbit above the Marshall Islands in the Pacific
(latitude 0 degrees North, longitude 180 degrees East).
]]
require("std")

local function main()
	local observer = celestia:sane()
	celestia:hide("cloudmaps", "cloudshadows")	--distracting
	local earth = celestia:find("Sol/Earth")
	earth:addreferencemark({type = "body axes"})
	earth:addreferencemark({type = "planetographic grid"})

	local bodyfixedFrame = celestia:newframe("bodyfixed", earth)
	observer:setframe(bodyfixedFrame)

	local microlightyears = 12 * earth:radius() / KM_PER_MICROLY
	local position = celestia:newposition(-microlightyears, 0, 0)
	observer:setposition(bodyfixedFrame:from(position))

	observer:lookat(
		bodyfixedFrame:from(std.position0),
		bodyfixedFrame:from(std.yaxis))
	wait()
end

main()

Run the program and fast forward with the keystrokes j, k, l. Since we have setframed the observer, he will adhere to the bodyfixed frame and remain above the Marshall Islands as the rest of the Universe rotates in the background.

Change "Sol/Earth" to "Sol/Venus" in the above program. Observe that the universe behind the planet is now upside down.

Do "Sol/Earth/Hubble" and "Sol/Cassini" have a bodyfixed frame?

Read the program in celestialSphere that keeps the observer fixed above the point at 0° North 0° East.

MercuryMercury, terminator of’s Hesitant Terminator

The terminatorterminator is the line on the surface of a planet that separates day from night. Let’s watch Mercury’s terminator hesitate at the meridian of 90° West. The observer is positioned on the bodyfixed positive Z axis, above the point where it pierces the equator at 90° W. The X axis points to the right, the Y axis points up and the Z axis points out of the window towards the user.

See solarDays for the computation of Mercury’s solar day, and sunsEye for another view of Mercury’s rotation.

--[[
Watch Mercury's terminator hesitate as we hover over the equator
at 90 degrees W.
]]
require("std")

local function main()
	local observer = celestia:sane()
	local mercury = celestia:find("Sol/Mercury")
	celestia:select(mercury)

	mercury:addreferencemark({type = "sun direction"})
	mercury:addreferencemark({type = "planetographic grid"})

	mercury:addreferencemark({	--Show the terminator as a yellow line.
		type = "visible region",
		target = celestia:find("Sol")
	})

	local bodyfixedFrame = celestia:newframe("bodyfixed", mercury)
	observer:setframe(bodyfixedFrame)

	local lat = math.rad(0)
	local long = math.rad(-90)	--west longitude is negative
	local microlightyears = 5 * mercury:radius() / KM_PER_MICROLY
	local position = celestia:newpositionlonglat(long, lat, microlightyears)
	observer:setposition(bodyfixedFrame:from(position))

	--The up vector is Mercury's axis of rotation.
	local up = bodyfixedFrame:from(std.yaxis)
	observer:lookat(mercury:getposition(), up)

	local info = mercury:getinfo()
	local orbitPeriod = info.orbitPeriod		--in Earth days
	local rotationPeriod = info.rotationPeriod	--in Earth days

	--length of Mercurian solar day in Earth days
	assert(orbitPeriod ~= rotationPeriod)
	local day =
		orbitPeriod * rotationPeriod / (orbitPeriod - rotationPeriod)

	--One Mercurian solar day of simulation time
	--per 12 seconds of real time.
	celestia:settimescale(60 * 60 * 24 * day / 12)
	wait()
end

main()

Change the observer’s longitude to or 180° to get a better view of the places where the yellow subsolar vector hesitates. What does it look like from above Mercury’s north pole?

Add the three reference marks with a for loop. The variable marks refers to an array of tables.

local marks = { {type = "sun direction"}, {type = "planetographic grid"}, {type = "visible region", target = celestia:find("Sol")} } for i, mark in ipairs(marks) do mercury:addreferencemark(mark) end

Mare MarginisMare Marginis (lunar sea) is at the edge of the hemisphere of the Moon that is visible from the Earth. Watch the edge of the visible region moving back and forth across the mare due to the librationlibration of Moon of the Moon.

celestia:hidelabel("moons") --We know that the Moon is the Moon. celestia:showlabel("locations") --locations on the Moon --enough light to see the mare during the long lunar night celestia:setambientCelestia:setambient(.2)

The line that marks the edge of the visible region should match the color of the body-to-body vector that points from the Moon towards the Earth. Get the color from the constructor for class BodyToBodyDirectionArrow in version/src/celengine/axisarrow.cpp.

local earth = moon:getinfo().parent moon:addreferencemark({ type = "body to body direction", target = earth }) moon:addreferencemark({ type = "visible region", target = earth, color = "#008000" --dark green, to match body-to-body vector })

Position the user above Mare Marginis, three lunar radii from the center of the Moon to be close enough to make the labels visible.

local mare = celestia:find("Sol/Earth/Moon/Mare Marginis") celestia:select(mare) local longlat = bodyfixedFrame:to(mare:getposition()):getlonglat() local microlightyears = 3 * moon:radius() / KM_PER_MICROLY local position = celestia:newpositionlonglat( longlat.longitude, longlat.latitude, microlightyears) observer:setposition(bodyfixedFrame:from(position)) --One sidereal month of simulation time per 12 seconds of real time. local orbitPeriod = moon:getinfo().orbitPeriod --in Earth days celestia:settimescale(60 * 60 * 24 * orbitPeriod / 12)

We’ll have another view of lunar libration in Libration.

Find the Quasarquasar

By great good fortune the moon, as seen from [the radio telescope at] ParkesParkes Observatory, passed three times across [the quasar] 3C 2733C 273 (quasar) in 1962. Since the position of the edge of the moon at any time is known very accurately, a careful timing of the disappearance and reappearance of the occulted source provides a very accurate position for it.… [A] beautiful diffraction pattern was obtained, not much inferior to the patterns that are used as illustrations in text-books on optics.… Then came the historic moment. [Maarten] SchmidtSchmidt, Maarten decided to see whether he could interpret the spectrum in terms of a substantial red shiftredshift… [T]he era of the QSO had begun.
D. W. Sciama, Modern Cosmology (1971)

In the 1950’s, Cambridge University issued a series of catalogs of the radio sources on the celestial sphere. The third catalog was referred to as 3C,3C (Cambridge catalog) and the 273th entry in it was 3C 273. Let’s pinpoint the right ascension and declination of this quasar.

For the times when the Moon covered and uncovered the radio source on August 5, 1962, as seen from the radio telescope at Parkes in Australia, see the bibliography in bibliography. The vectors A and B point from Parkes to the Moon at these two times. We give them uppercase names because we will think of them as points on the celestial sphere; see the following diagram. The circles are the limbs (outlines) of the Moon at the two times. The points we want to find are C and D. Point E is the intersection of line c with the limb of the first Moon. Point O, not shown in the diagram, is the center of the Earth and of the celestial sphere. It is located right between the reader’s eyes.

The sides of the triangles are great circlesgreat circle on the celestial sphere, so their lengths can be represented in degrees or radians of arc. For example, the length of side c can be represented as the angle AOB. The following formula will use lowercase letters for the size of angles whose vertex is the center of the celestial sphere, and uppercase for angles whose vertex is on the surface of the sphere. For example, c is the size of angle AOB and C is the size of angle ACB.

We already know the lengths of the sides of the triangle. The spherical law of cosinesspherical law of cosines will give us the size of the angle A.

cos a = cos b cos c + sin b sin c cos A

We can solve it for cos A

cos A = cos a − cos b cos c sin b sin c and then for A itself: A = arcsin cos a − cos b cos c sin b sin c

In the following program, the vectors A and B have been renamed toMoon[1] and toMoon[2] respectively, since it is easier to loop through them if they are array elements. The angles b and a have been renamed radius[1] and radius[2] respectively, and are measured in radians. Point E has been renamed toLimb. The first call to Rotation:transform (transformVector) rotates point A (a.k.a. toMoon[1]) through an angle of b (a.k.a. radius[1]) to create point E (a.k.a. toLimb). The second and third calls to transform rotate point E along the limb of the first Moon through an angle of positive and negative A radians; we try both signs with a for loop. These rotations get us to the desired points C and D.

--[[
Display the Right Ascension and Declination of the two points that were on the
limb of the Moon as seen from the Parkes Observatory at both of the two given
times.  These points are the possible locations of quasar 3C 273.
]]

require("std")

local function main()
	local observer = celestia:sane()
	celestia:settimescale(0)
	local earth = celestia:find("Sol/Earth")
	local moon = celestia:find("Sol/Earth/Moon")

	local bodyfixedFrame = celestia:newframe("bodyfixed", earth)
	observer:setframe(bodyfixedFrame)

	--Parkes Observatory, New South Wales, Australia
	local position = celestia:newpositionlonglat(
		math.rad(  148 + (15 + 44.3 / 60) / 60),
		math.rad(-( 32 + (59 + 59.8 / 60) / 60)),
		(earth:radius() + .324) / KM_PER_MICROLY)

	observer:setposition(bodyfixedFrame:from(position))

	local t = {
		celestia:utctotdb(1962, 8, 5, 7, 46, 0.0),  --Moon covers 3C 273
		celestia:utctotdb(1962, 8, 5, 9, 5, 45.5) --Moon uncovers 3C 273
	}
	local toMoon = {}	--vectors from Parkes to Moon
	local radius = {}	--angular radii of Moon as seen from Parkes

	for i = 1, #t do
		celestia:settime(t[i])
		toMoon[i] = moon:getposition() - observer:getposition()
		local moonDistance = toMoon[i]:length() * KM_PER_MICROLY
		radius[i] = math.asin(moon:radius() / moonDistance)
	end

	local axis = toMoon[2] ^ toMoon[1]
	assert(axis:length() > 0)
	local rotation = celestia:newrotation(axis:normalize(), radius[1])
	local toLimb = rotation:transform(toMoon[1])

	--angle A derived from spherical law of cosines
	axis = toMoon[1]:normalize()
	local c = toMoon[1]:angle(toMoon[2])
	local product = math.sin(radius[1]) * math.sin(c)
	assert(product > 0)
	local A = math.acos(
		(math.cos(radius[2]) - math.cos(radius[1]) * math.cos(c))
		/ product)

	local radecFrame = celestia:newframe("universal", std.radecRotation)
	local toQuasar = {}

	local s = string.format(
		"Actual position of quasar 3C 273:\n"
		.. "RA 12h 29m 6.7s Dec 2%s 3%s 9%s\n\n"
		.. "Computed positions:\n",
		std.char.degree, std.char.minute, std.char.second)

	for i, sign in ipairs({1, -1}) do
		rotation = celestia:newrotation(sign * axis, A)
		toQuasar[i] = rotation:transform(toLimb)

		local longlat = radecFrame:to(toQuasar[i]):getlonglat()
		local ra = std.tora(longlat.longitude)
		local dec = std.todec(longlat.latitude)

		s = s .. string.format(
			"RA %dh %dm %.2gs Dec %s%d%s %d%s %.2g%s\n",
			ra.hours, ra.minutes, ra.seconds,
			std.sign(dec.signum),
			dec.degrees, std.char.degree,
			dec.minutes, std.char.minute,
			dec.seconds, std.char.second)
	end

	celestia:mmprint(s, math.huge, -1, 1, 1, -1)
	observer:setfovObserver:setfov(3 * radius[2])	--Moon spans 2/3 of height of window
	local orientation =
		celestia:newrotationforwardup(toQuasar[2], std.yaxis)

	--Blink back and forth between the two times.
	while true do
		for i = 1, #t do
			celestia:settime(t[i])
			observer:setorientation(orientation)
			wait(3)
		end
	end
end

main()
Actual position of quasar 3C273: RA 12h 29m 6.7s Dec 2° 3′ 9″ Computed positions: RA 12h 28m 52s Dec +1° 57′ 4.5″ RA 12h 29m 5.9s Dec +2° 3′ 8.2″

The 64-meter radio telescope at Parkes can tilt down to 60° from the zenith. Read about altitude and azimuth in altazimuthCoordinates and see the Bibliography for the times of the other occultations of 3C 273 by the Moon in 1962. Were any of them at or just below the 60° limit? Is there any truth to the story that chainsaw wielding astrophysicists climbed the scaffolding during the observations and cut off the guard rails to allow the telescope to swing lower?

Track a Celestial Object

The OFA [Old Farmer’s AlmanackOld Farmer’s Almanack] has long been known for its “accurate” weather forecasts. In previous editions these have been made for Boston and New England only, with the proviso these could be used elsewhere by considering the weather as forecast would arrive one day earlier for each Time Zone west of Boston.
The Old Farmer’s Almanack, 1968

Let’s rotate the observer around the Earth in step with the above model of the weather. We will start at time t0 at latitude North longitude Eeast over the South Atlantic and keep him over the same cloud. His motion will be programmed with respect to the Earth’s bodyfixed frame.

--[[
Stay with the clouds as they rotate around the Earth.
Keep the observer over the cloud that was at 0 N 0 E at time t0.
Watch the continents rotate from east to west under the clouds.
]]
require("std")

--variables used by tickHandler:
local t0 = celestia:gettime()
local observer = celestia:sane()
local earth = celestia:find("Sol/Earth")
local bodyfixedFrame = celestia:newframe("bodyfixed", earth)

--position in Earth's bodyfixed frame at time t0
local microlightyears = 5 * earth:radius() / KM_PER_MICROLY
local position0 =
	celestia:newpositionlonglat(math.rad(0), math.rad(0), microlightyears)

--How many radians do the clouds travel per day in Earth's bodyfixed frame?
local radiansPerDay = earth:getinfo().atmosphereCloudSpeed

local function tickHandler()
	local t = celestia:gettime() - t0	--number of days since t0
	local rotation = celestia:newrotation(-std.yaxis, t * radiansPerDay)
	observer:setposition(bodyfixedFrame:from(rotation:transform(position0)))

	observer:lookat(
		bodyfixedFrame:from(std.position0),
		bodyfixedFrame:from(std.yaxis))
end

local function main()
	celestia:hidelabel("planets")
	earth:addreferencemark({type = "body axes"})
	observer:setframe(bodyfixedFrame)

	--one simulation day per 15 seconds of real time
	celestia:settimescale(60 * 60 * 24 / 15)
	celestia:registereventhandler("tick", tickHandler)
	wait()
end

main()

Here’s another way to keep the observer aimed at the center of the Earth, with the bodyfixed Y axis pointing up. Create the following orientation immediately after the local position0. --The orientation that would make an observer at position0 look towards the --center of the Earth, with the Y axis pointing up. local orientation0 = celestia:newrotationforwardup(std.position0 - position0, std.yaxis) Then change the call to Observer:lookatObserver:lookat to the following. observer:setorientation(bodyfixedFrame:from(rotation * orientation0))

A handler called 60 times per second should be as fast as possible. It would therefore be desirable to remove the Observer:lookatObserver:lookat and the Observer:setorientationObserver:setorientation from the handler. But how will we keep the observer aimed at the center of the Earth, with Polaris up?

The method Observer:trackObserver:track seems promising. It keeps the observer pointedtrack a celestial object at a celestial object by automatically executing the following code with each tick of the Celestia simulation.

--Get observer's current up vector with respect to the universal frame. local up = observer:getorientation():transform(std.up) --Center the tracked object in the observer's field of view. observer:lookat(theTrackedObject:getposition(), up)

At first glance, the code seems to keep the observer’s up vector unchanged. But track actually offers no such guarantee. For example, consider an observer whose tracked object is at Taurus/Gemini and whose zenith is at Draco. If the tracked object moves down to Orion, the observer’s zenith will move down to Polaris. So far, this seems harmless: his up vector has moved down the solstitial colure, as it had to, but it has not strayed to the left or right. If the tracked object now moves to the right along the celestial equator, from Orion to Pisces, the up vector will remain pointing to Polaris. This seems even better: his up vector has remained completely motionless. But his original up vector, pointing towards Draco, is now pointing off towards the observer’s current upper right. His current zenith, near Polaris, has wandered away from his original zenith in Draco.

We can still use Observer:track in this program, but it requires care. We will avoid the “wandering zenith” problem by positioning and orienting the observer before we start tracking the Earth. Insert the following code into the main function immediately after the Observer:setframe. Then remove the Observer:lookat or setorientation in the handler.

--Call the tick handler once to posi