Sky science
Weather and clouds
What weather data the app fetches, what cloud cover means, how seeing is estimated today, and which forecast variables are not used yet.
Where weather comes from
Weather comes from Open-Meteo, which combines forecasts from several national weather services (ECMWF, NOAA, Météo-France, DWD, the UK Met Office, JMA and others) behind one keyless interface. The app calls it directly. A Cloudflare proxy exists behind a feature flag that is off.
Astr does not model weather. It reads a forecast, hour by hour, for the location's coordinates.
What is fetched today
One request returns an hourly series for 10 past days and 16 forecast days (the maximum Open-Meteo offers) in these variables:
| Quantity | Open-Meteo variable | Unit | Used for |
|---|---|---|---|
| Air temperature at 2 m | temperature_2m | °C | temperature, and the seeing estimate |
| Relative humidity at 2 m | relativehumidity_2m | % | humidity, and the seeing estimate |
| Total cloud cover | cloudcover | % of sky | cloud cover |
| Wind speed at 10 m | windspeed_10m | km/h | wind, and the seeing estimate |
| Weather code | weathercode | WMO code | weather icon |
What cloud cover is
Cloud cover is not calculated by Astr. It is a variable from a numerical weather model: the fraction of the sky above a grid cell (about 11 km across) that the model puts cloud in, from 0% to 100%.
It means a share of the sky, not a probability that a given star is hidden. A forecast of 30% means about a third of the sky is covered, and the model does not say which third. It also cannot see clouds smaller than a grid cell or the shape of one hill's weather.
How it is used today
- For today, the home screen takes the forecast hour closest to the current time. That is the cloud now, which can be daytime cloud, not the cloud tonight.
- For another date, it takes the day's average cloud cover from the seven-day forecast.
- The conditions graph plots the hourly values across the night as an area, and the prime-view window weights cloud at 70% (see Graphs).
The sky-state model replaces this with the hourly values for the night's dark hours (see Sky states).
Seeing
Seeing is how much the air blurs a star's image, which matters for planets and double stars. The true Pickering scale is an observer's judgement of a star's diffraction pattern through a telescope. The app does not have that, so it uses a proxy built from ground weather and labels it with the Pickering name.
| Symbol | Meaning | Rule |
|---|---|---|
| penalty for the temperature range over the next 3 hours | 2 if , 1 if | |
| penalty for the wind speed now | 3 if km/h, 2 if , 1 if | |
| bonus for stable humid air | 1 if humidity is over 70% and | |
| score | the result, 1 to 10, and its label | 9 to 10 Excellent, 7 to 8 Good, 5 to 6 Fair, 3 to 4 Poor, 1 to 2 Extremely Poor |
This is a heuristic, not a measurement, and its thresholds are not validated against observations (unverified). The physics suggests a better proxy: turbulence in the free atmosphere follows the wind shear near the jet stream (200 to 300 hPa), and the lowest kilometre follows heat flux from the ground (SPIE, "Using meteorological forecasts to predict astronomical seeing"). Open-Meteo provides wind at 250 and 300 hPa and the boundary-layer height. A replacement built on those is a proposal, to be compared against an independent seeing source before it ships.
Transparency
Transparency is how clear the air is, set mostly by aerosols and water vapour. The app has no transparency measure today; it shows humidity. The sky model uses an extinction coefficient from aerosol optical depth (see Moonlight and sky brightness), which needs a variable the app does not fetch yet.
Available but not used
| Quantity | Open-Meteo variable | What it gives | Would improve |
|---|---|---|---|
| Cloud cover by height | cloud_cover_low, _mid, _high | low under 3 km, mid 3 to 8 km, high above 8 km | telling thin high cirrus from thick low cloud |
| Visibility | visibility | horizontal viewing distance in metres, affected by low cloud, humidity and aerosols | transparency |
| Wind near the jet stream | wind_speed_250hpa, wind_speed_300hpa | wind speed at those pressure levels | seeing |
| Boundary layer depth | boundary_layer_height | depth of the turbulent layer next to the ground | seeing |
| Precipitable water | total_column_integrated_water_vapour | water vapour in the whole column of air | transparency |
| Aerosol optical depth, dust | aerosol_optical_depth, dust (Air Quality API) | haze and Saharan dust, 0.4° grid, every 3 hours globally | extinction |
Terms of use
The free tier is for non-commercial use, with fewer than 10,000 calls a day, 5,000 an hour and 600 a minute. Apps with subscriptions or advertisements count as commercial. Data is under CC BY 4.0, so Open-Meteo must be credited, and the Air Quality data must also credit CAMS. Astr is planned to be free, open source and tip-funded. Whether tips count as non-commercial is not stated in the terms, so it should be confirmed with Open-Meteo before the first store release (unverified).
Implementation
Excerpts of the real files, cut out by name, copied into the site and checked against the repository on every build.
/// Fetches hourly weather forecast for the next 16 days (Open-Meteo max)/// Returns map with hourly arrays for: temperature_2m, relativehumidity_2m, cloudcover, windspeed_10m/// AC#3: Extended from 7 to 16 days to support +/- 10 day cloud cover windowFuture<Map<String, dynamic>> getHourlyForecast(GeoLocation location) async { final Response response = await _dio.get( '${ApiConfig.weatherBaseUrl}/forecast', queryParameters: <String, dynamic>{ 'latitude': location.latitude, 'longitude': location.longitude, 'hourly': 'temperature_2m,relativehumidity_2m,cloudcover,windspeed_10m,weathercode', 'forecast_days': 16, // AC#3: Extended from 7 to 16 (Open-Meteo max) 'past_days': 10, // AC#3: Include 10 days of historical data }, ); if (response.statusCode == 200) { final data = response.data; final hourly = data['hourly']; if (hourly != null) { return <String, dynamic>{ 'time': (hourly['time'] as List).cast<String>(), 'temperature': (hourly['temperature_2m'] as List).map((e) => (e as num?)?.toDouble() ?? 0.0).toList(), 'humidity': (hourly['relativehumidity_2m'] as List).map((e) => (e as num?)?.toDouble() ?? 0.0).toList(), 'cloudCover': (hourly['cloudcover'] as List).map((e) => (e as num?)?.toDouble() ?? 0.0).toList(), 'windSpeed': (hourly['windspeed_10m'] as List).map((e) => (e as num?)?.toDouble() ?? 0.0).toList(), 'weatherCode': (hourly['weathercode'] as List).map((e) => (e as num?)?.toDouble() ?? 0.0).toList(), }; } } throw Exception('Failed to fetch hourly forecast data');}return WeatherNotifier();/// SeeingCalculator Service - AC#2, AC#4/// Calculates atmospheric seeing quality using Pickering Scale (1-10) heuristic/// /// **AC#2: Heuristic Calculation Model**/// The true Pickering scale is observational (astronomer visually assesses star diffraction/// pattern through telescope). This implementation uses meteorological data as a proxy./// /// **AC#4: Algorithm Logic**/// - Base Score: Start at 10 (perfect seeing)/// - Temperature Gradient Penalty:/// * variance > 5°C over 3 hours: -2 points (high turbulence)/// * variance > 3°C over 3 hours: -1 point (moderate turbulence)/// - Wind Speed Penalty:/// * wind > 30 km/h: -3 points (severe atmospheric mixing)/// * wind > 20 km/h: -2 points/// * wind > 10 km/h: -1 point/// - Humidity Bonus:/// * humidity > 70% with stable temp (<3°C variance): +1 point (stable air mass)/// - Final Score: Clamp to 1-10 range/// /// **Research Citations:**/// - Pickering, William H. (Harvard College Observatory) - Original seeing scale definition/// - "Atmospheric Seeing" - Wikipedia (https://en.wikipedia.org/wiki/Astronomical_seeing)/// - Atmospheric turbulence factors: Temperature gradients, wind shear, humidity/// (Sources: SPIE, astrobackyard.com)library;class SeeingCalculator { /// Calculates Pickering Seeing score (1-10) from hourly weather data /// /// AC#4: Input requires at least 3 hours of data for temperature variance calculation /// Returns tuple: (score, label) /// /// Labels (AC#1): /// - 1-2: "Extremely Poor" /// - 3-4: "Poor" /// - 5-6: "Fair" /// - 7-8: "Good" /// - 9-10: "Excellent" (int, String) calculateSeeing({ required List<double> temperatures, // Hourly temperatures in °C (AC#3) required List<double> windSpeeds, // Hourly wind speeds in km/h (AC#3) required List<double> humidities, // Hourly humidity percentages (AC#3) }) { // Validate input data if (temperatures.isEmpty || windSpeeds.isEmpty || humidities.isEmpty) { return (5, 'Fair'); // Default to middle score if no data } // Use current hour (index 0) for instant calculations final double currentWind = windSpeeds[0]; final double currentHumidity = humidities[0]; // Calculate temperature variance over 3 hours (AC#4) final double tempVariance = _calculateTemperatureVariance(temperatures); // AC#4: Start with base score (perfect seeing) int score = 10; // AC#4: Temperature Gradient Penalty if (tempVariance > 5.0) { score -= 2; // High turbulence } else if (tempVariance > 3.0) { score -= 1; // Moderate turbulence } // AC#4: Wind Speed Penalty if (currentWind > 30.0) { score -= 3; // Severe atmospheric mixing } else if (currentWind > 20.0) { score -= 2; } else if (currentWind > 10.0) { score -= 1; } // AC#4: Humidity Bonus (stable air mass) if (currentHumidity > 70.0 && tempVariance < 3.0) { score += 1; } // AC#4: Clamp final score to 1-10 range score = score.clamp(1, 10); // AC#1: Map score to descriptive label final String label = _getSeeingLabel(score); return (score, label); } /// Calculates temperature variance over 3-hour window /// Returns maximum temperature difference in °C double _calculateTemperatureVariance(List<double> temperatures) { if (temperatures.length < 3) { // Not enough data for 3-hour window, use available data if (temperatures.length == 1) return 0; return (temperatures.reduce((double a, double b) => a > b ? a : b) - temperatures.reduce((double a, double b) => a < b ? a : b)).abs(); } // Use first 3 hours (indices 0, 1, 2) final List<double> threeHourData = temperatures.take(3).toList(); final double maxTemp = threeHourData.reduce((double a, double b) => a > b ? a : b); final double minTemp = threeHourData.reduce((double a, double b) => a < b ? a : b); return (maxTemp - minTemp).abs(); } /// Maps Pickering score to descriptive label (AC#1) String _getSeeingLabel(int score) { if (score >= 9) return 'Excellent'; // 9-10 if (score >= 7) return 'Good'; // 7-8 if (score >= 5) return 'Fair'; // 5-6 if (score >= 3) return 'Poor'; // 3-4 return 'Extremely Poor'; // 1-2 }}