Skip to content

Add Protection Cell Air-Defense Coverage example, analyzer, scenario, and tests - #24

Open
jakexcosme wants to merge 4 commits into
developfrom
devin/air-defense-coverage-symbology
Open

Add Protection Cell Air-Defense Coverage example, analyzer, scenario, and tests#24
jakexcosme wants to merge 4 commits into
developfrom
devin/air-defense-coverage-symbology

Conversation

@jakexcosme

@jakexcosme jakexcosme commented Jul 13, 2026

Copy link
Copy Markdown

Description of the Change

Adds a self-contained Protection Cell Air-Defense Coverage capability to the examples, following the pattern of the existing TacticalSymbols / TacticalGraphics symbology examples. It renders a common operating picture on the 3D globe and computes air-defense protection coverage — the class of decision aid an Army protection cell uses.

New files (no changes to existing files, no public API changes):

  • src/gov/nasa/worldwindx/examples/symbology/AirDefenseCoverage.java — runnable example (main, launchable like every other example). Loads the scenario, renders each entity as a MIL-STD-2525 MilStd2525TacticalSymbol on a RenderableLayer (friendly assets → friendly SIDCs, threats → hostile air-defense SIDCs), draws each threat's weapon-engagement zone as a translucent hostile-red range volume (CappedCylinder), flags exposed assets with a red callout, and prints/shows a coverage summary.
  • src/gov/nasa/worldwindx/examples/symbology/ThreatCoverageAnalyzer.java — rendering-free helper holding the exposure logic (Asset, Threat, AssetExposure, CoverageReport). Exposure = LatLon.greatCircleDistance(asset, threat) * globeRadius <= wezRadius.
  • src/config/AirDefenseCoverageScenario.csv — bundled scenario (14 friendly assets + 4 hostile IADS entries) over a Baltic AO using publicly known place names. Threat rings are open-source coverage estimates from published nominal system ranges; no classified data.
  • test/gov/nasa/worldwindx/examples/symbology/ThreatCoverageAnalyzerTest.java — JUnit4 geometry tests (asset just inside vs. just outside a ring, on-boundary, multiple overlapping threats, single-threat exposure, covered asset, report counts, argument validation).

Pseudocode of the core decision:

for asset in assets:
    exposingThreats = [ t for t in threats
                        if greatCircleDistance(asset.loc, t.loc) * globeRadius <= t.wezRadius ]
    asset.exposed = exposingThreats not empty
report = "N of M assets EXPOSED" + per-asset (exposed-by-which-threat | covered)

Verified output on the bundled scenario: 12 of 14 assets exposed, 2 covered (the two northernmost nodes fall outside every ring).

Why Should This Be In Core?

It exercises existing core symbology (MilStd2525TacticalSymbol), airspace primitives (CappedCylinder), annotations (GlobeAnnotation), and geometry (LatLon.greatCircleDistance) end-to-end as an operator-recognizable decision aid, and gives the examples suite a concrete protection-planning workflow. It is additive and isolated to worldwindx.examples + config.

Benefits

  • Data-driven: the scenario is a bundled resource; edit the CSV to change the laydown with no code changes.
  • Doctrinally correct MIL-STD-2525 affiliation coloring (friendly blue / hostile red) and hostile-red WEZ volumes.
  • Coverage math is isolated in a testable, WorldWind-render-free class with JUnit coverage of the boundary cases.

Potential Drawbacks

  • MIL-STD-2525 icon glyphs depend on the symbol tile set; where a specific glyph is unavailable the symbol still renders as a correctly-colored 2525 frame. Affiliation and geometry are unaffected.
  • Threat rings are modeled as flat-capped cylinders (ground-footprint WEZ), not full 3D engagement envelopes.

Applicable Issues

None.


Reproduce from text (paste into Ask Devin)

Work in the repo COG-GTM/WorldWindJava. Build a new, self-contained "Protection Cell Air-Defense Coverage" capability that renders a live common operating picture on the 3D globe and computes protection coverage — the same class of decision aid an Army protection cell uses.

Requirements:
1. Add a runnable example class `gov.nasa.worldwindx.examples.symbology.AirDefenseCoverage` (follow the pattern of the existing `src/gov/nasa/worldwindx/examples/symbology/TacticalSymbols.java` and `TacticalGraphics.java` examples) that opens a WorldWind window over a realistic AO.
2. Load a small scenario from a bundled resource file (JSON or CSV placed under `src/config` or alongside the example) containing: a set of friendly assets (lat/lon, name, type) and threats (lat/lon, name, weapon-engagement-zone radius in km).
3. Render each entity as a proper MIL-STD-2525 symbol using `gov.nasa.worldwind.symbology.milstd2525.MilStd2525TacticalSymbol` with correct SIDC codes (friendly assets = friendly symbols; threats = hostile air-defense symbols) on a `RenderableLayer`.
4. For each threat, draw its weapon-engagement-zone / threat ring as a translucent range volume using existing primitives (`gov.nasa.worldwind.render.airspaces.SphereAirspace` or `CappedCylinder`, or the 2525 `gov.nasa.worldwind.symbology.milstd2525.graphics.areas.CircularRangeFan`), with hostile-red attributes.
5. Compute protection coverage: for every friendly asset, determine whether it falls inside any threat ring using `gov.nasa.worldwind.geom.LatLon.greatCircleDistance` / `Position` math. Visually flag exposed assets (e.g. red highlight / callout) vs. covered ones, and print a coverage summary (N of M assets exposed, and by which threat).
6. Add a `ThreatCoverageAnalyzer` helper class holding the exposure logic, and add JUnit tests covering the geometry (asset just inside vs. just outside a ring, multiple overlapping threats).
7. Make it build cleanly with `ant -f release-build.xml` (baseline OpenJDK 11) with no changes to existing public APIs. Keep the example launchable from the standard examples menu.

Use real, publicly-known place names and realistic force-structure naming for the scenario data so a defense audience recognizes the domain (do NOT invent classified data). `CircularRangeFan` and `SectorRangeFan` already exist under `src/gov/nasa/worldwind/symbology/milstd2525/graphics/areas/`, so you may reuse doctrinal 2525 graphics for the rings instead of airspaces if cleaner.

Deliverable: open a PR to COG-GTM/WorldWindJava with the new example, the analyzer, the scenario data file, and the tests. Verify `ant -f release-build.xml` succeeds and the JUnit tests pass before opening the PR.

Agent Audit Trail (AGENTS.md §6)

1. Prompt block — see "Reproduce from text" above (verbatim task prompt).

2. Session URLhttps://app.devin.ai/sessions/6be415ce16254dec9052c52bcbd7c07c

3. Devin Review verdict — Pass-with-notes. Devin Review flagged 5 items (1 repo-rule warning + 4 info); all addressed in commit b3bf60e4: (a) log-then-throw on the missing-scenario path; (b) close the underlying stream even if reader construction fails; (c) per-line CSV parsing that logs and skips a malformed record instead of aborting the load; (d) MIL-STD-2525 echelon differentiation so long/medium/short-range SAMs are visually distinct; (e) test-coverage note — no change needed. A second review pass raised 3 further info nits, all resolved in 2959453d: warn on a THREAT row with a missing/empty radius, consistent \n line endings in the summary, and removal of an unused test constant. No unresolved findings.

4. Plain-English rationale — This PR adds an operator-recognizable protection-cell decision aid: it draws a MIL-STD-2525 common operating picture, shows hostile air-defense weapon-engagement zones as translucent range volumes, and computes which friendly assets sit inside those zones. It is entirely new, isolated code (one example, one analyzer, one data file, one test), so nothing existing changes.

5. Metrics block — Capability-extension PR (not a Java 21 modernization pass), so modernization counters are all 0:

  • Anonymous-inner-class conversions: 0
  • instanceof → pattern-match conversions: 0
  • Records introduced: 0
  • Sealed hierarchies introduced: 0
  • Deprecated-API usage removals: 0
  • Text-block conversions: 0
  • New files added: 4 (example, analyzer, scenario CSV, JUnit test)

6. Public API delta — No public API surface changes. All additions live in gov.nasa.worldwindx.examples.symbology and src/config; no signatures in gov.nasa.worldwind.* were touched.


Build & test gates

ant -f release-build.xml     # BUILD SUCCESSFUL (baseline worldwind.jdk=11)

The default build target runs the full JUnit suite: 50 test suites passed, 0 failures / 0 errors, including the new ThreatCoverageAnalyzerTest (10 tests, all green).

Requested by: @jakexcosme


Devin Review

Status Commit
⚪ Not started

Run Devin Review

💡 Connect your GitHub account to enable automatic code reviews.

Open in Devin Review (Staging)
Open in Devin Review

@jakexcosme jakexcosme self-assigned this Jul 13, 2026
@devin-ai-integration

Copy link
Copy Markdown
Original prompt from Jake

Work in the repo COG-GTM/WorldWindJava. Build a new, self-contained "Protection Cell Air-Defense Coverage" capability that renders a live common operating picture on the 3D globe and computes protection coverage — the same class of decision aid an Army protection cell uses.

Requirements:

  1. Add a runnable example class gov.nasa.worldwindx.examples.symbology.AirDefenseCoverage (follow the pattern of the existing src/gov/nasa/worldwindx/examples/symbology/TacticalSymbols.java and TacticalGraphics.java examples) that opens a WorldWind window over a realistic AO.
  2. Load a small scenario from a bundled resource file (JSON or CSV placed under src/config or alongside the example) containing: a set of friendly assets (lat/lon, name, type) and threats (lat/lon, name, weapon-engagement-zone radius in km).
  3. Render each entity as a proper MIL-STD-2525 symbol using gov.nasa.worldwind.symbology.milstd2525.MilStd2525TacticalSymbol with correct SIDC codes (friendly assets = friendly symbols; threats = hostile air-defense symbols) on a RenderableLayer.
  4. For each threat, draw its weapon-engagement-zone / threat ring as a translucent range volume using existing primitives (gov.nasa.worldwind.render.airspaces.SphereAirspace or CappedCylinder, or the 2525 gov.nasa.worldwind.symbology.milstd2525.graphics.areas.CircularRangeFan), with hostile-red attributes.
  5. Compute protection coverage: for every friendly asset, determine whether it falls inside any threat ring using gov.nasa.worldwind.geom.LatLon.greatCircleDistance / Position math. Visually flag exposed assets (e.g. red highlight / callout) vs. covered ones, and print a coverage summary (N of M assets exposed, and by which threat).
  6. Add a ThreatCoverageAnalyzer helper class holding the exposure logic, and add JUnit tests covering the geometry (asset just inside vs. just outside a ring, multiple overlapping threats).
  7. Make it build cleanly with ant -f release-build.xml (baseline OpenJDK 11) with no ... (1033 chars truncated...)

@devin-ai-integration

Copy link
Copy Markdown

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 5 potential issues.

Open in Devin Review

Comment thread src/gov/nasa/worldwindx/examples/symbology/AirDefenseCoverage.java Outdated
Comment on lines +395 to +399
protected static String sidcForThreat(String type)
{
// All modeled threats are hostile air-defense (surface-to-air) systems.
return "SHGPUCD--------";
}

@devin-ai-integration devin-ai-integration Bot Jul 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Threat type mapping always returns the same SIDC regardless of input

The sidcForThreat method at line 432-436 accepts a type parameter but ignores it entirely, always returning "SHGPUCD--------". The comment explains this is intentional ("All modeled threats are hostile air-defense systems"), and the echelon modifier is used to visually distinguish SAM_LONG/MEDIUM/SHORT. However, if the scenario CSV is extended with non-air-defense threat types (e.g., coastal defense missiles), this method would silently render them with the wrong symbol. Consider whether a switch similar to sidcForAsset would be more future-proof.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in b3bf60e. The icon stays hostile air-defense (all threats are SAM systems), but addThreatSymbol now sets a MIL-STD-2525 echelon modifier per class via the new echelonForThreat helper: SAM_LONG → battalion, SAM_MEDIUM → battery, SAM_SHORT → platoon. Long/medium/short systems are now visually distinguishable on the map (in addition to the WEZ ring size and the - WEZ N km label).

Comment thread src/gov/nasa/worldwindx/examples/symbology/AirDefenseCoverage.java Outdated
Comment thread src/gov/nasa/worldwindx/examples/symbology/AirDefenseCoverage.java Outdated
@RunWith(JUnit4.class)
public class ThreatCoverageAnalyzerTest
{
private static final double DELTA = 1e-6;

@devin-ai-integration devin-ai-integration Bot Jul 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Test suite covers boundary, overlap, and count scenarios thoroughly

The test file at test/gov/nasa/worldwindx/examples/symbology/ThreatCoverageAnalyzerTest.java uses a locationAtDistance helper that computes positions via the same spherical model the analyzer uses, ensuring round-trip distance accuracy. Tests cover: just-inside (99 km vs 100 km ring), just-outside (101 km), exact boundary (<=), overlapping threat rings, single-threat exposure, covered assets, report counts, and invalid-argument rejection. The DELTA constant at line 49 is declared but unused — it's harmless but could be cleaned up.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — no action needed. Boundary (just-inside/just-outside/on-ring), multiple-overlap, single-threat, covered, report counts, and argument-validation cases are all covered.

…lience, threat echelon differentiation; drop author tags

Co-Authored-By: Jake Cosme <[email protected]>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

Open in Devin Review

Comment thread test/gov/nasa/worldwindx/examples/symbology/ThreatCoverageAnalyzerTest.java Outdated
Comment on lines +377 to +379
double radiusKm = fields.length > 5 && !fields[5].trim().isEmpty()
? Double.parseDouble(fields[5].trim()) : 0.0;
scenario.threats.add(new Threat(name, type, location, radiusKm * 1000.0));

@devin-ai-integration devin-ai-integration Bot Jul 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: THREAT entries without a radius silently default to zero

In parseScenarioLine at src/gov/nasa/worldwindx/examples/symbology/AirDefenseCoverage.java:377-378, if a THREAT CSV line has fewer than 6 fields or an empty radius field, the radius defaults to 0.0 km. This creates a degenerate threat with a zero-meter weapon-engagement zone that covers no assets. The current CSV data always provides radii for THREAT entries, but a future scenario author might omit the field expecting a sensible default and get no coverage ring with no warning logged.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2959453. parseScenarioLine now logs a generic.CannotParse warning when a THREAT row omits or leaves the radius field empty, so a future author gets a visible warning instead of a silent zero-radius (no-coverage) ring.

…nt line endings, drop unused test constant

Co-Authored-By: Jake Cosme <[email protected]>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 new potential issues.

Open in Devin Review

Comment on lines +378 to +382
if (!hasRadius)
Logging.logger().warning(Logging.getMessage("generic.CannotParse", line));

double radiusKm = hasRadius ? Double.parseDouble(fields[5].trim()) : 0.0;
scenario.threats.add(new Threat(name, type, location, radiusKm * 1000.0));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Malformed threat entry is warned about but still silently added with a zero-radius default

A threat record missing its radius is logged as unparseable (Logging.logger().warning(...) at src/gov/nasa/worldwindx/examples/symbology/AirDefenseCoverage.java:379) but is still added to the scenario with a zero-meter radius instead of being skipped, so a degenerate zero-radius threat ring is rendered and the threat appears in the coverage report without ever exposing any asset.

Impact: A malformed threat entry produces a misleading warning, a zero-size rendered volume, and an inert entry in the coverage analysis instead of being cleanly skipped like other malformed records.

Inconsistent error-handling pattern compared to other malformed-line cases

Every other malformed-data branch in parseScenarioLine warns and then returns (skips the record):

  • fields.length < 5 at AirDefenseCoverage.java:356-359: warns and returns.
  • Unknown kind at AirDefenseCoverage.java:386: warns (implicit return at end of else).

But the missing-radius branch at lines 378-382 warns and then falls through to add the threat with radiusKm = 0.0. This creates a Threat with radiusMeters = 0, which is then passed to addThreatVolume at line 120, creating a CappedCylinder with radius 0 — a degenerate shape. The threat also appears in the CoverageReport but can never expose any asset (since distance <= 0 is only true for co-located points).

The fix should either skip the threat (add a return after the warning, consistent with other branches) or not emit the warning if 0 is an intentional default.

Suggested change
if (!hasRadius)
Logging.logger().warning(Logging.getMessage("generic.CannotParse", line));
double radiusKm = hasRadius ? Double.parseDouble(fields[5].trim()) : 0.0;
scenario.threats.add(new Threat(name, type, location, radiusKm * 1000.0));
if (!hasRadius)
{
Logging.logger().warning(Logging.getMessage("generic.CannotParse", line));
return;
}
double radiusKm = Double.parseDouble(fields[5].trim());
scenario.threats.add(new Threat(name, type, location, radiusKm * 1000.0));
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 6ca7f72. A THREAT row missing/empty radius now warns and returns (skipped) instead of falling through to a zero-radius Threat, matching the other malformed-record branches. No degenerate zero-radius CappedCylinder or inert coverage-report entry is created.

Comment on lines +134 to +137
ThreatCoverageAnalyzer analyzer = new ThreatCoverageAnalyzer(
this.getWwd().getModel().getGlobe() != null
? this.getWwd().getModel().getGlobe().getRadius()
: ThreatCoverageAnalyzer.DEFAULT_GLOBE_RADIUS_METERS);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Globe.getRadius() resolves through the Extent interface, not Globe directly

The call this.getWwd().getModel().getGlobe().getRadius() at src/gov/nasa/worldwindx/examples/symbology/AirDefenseCoverage.java:136 initially appears problematic because the Globe interface (src/gov/nasa/worldwind/globes/Globe.java) does not declare getRadius(). However, Globe extends WWObject, Extent (line 68 of Globe.java), and Extent declares double getRadius() at src/gov/nasa/worldwind/geom/Extent.java:62. The concrete EllipsoidalGlobe.getRadius() returns the equatorial radius, which matches Earth.WGS84_EQUATORIAL_RADIUS used as the analyzer's default. This is consistent but non-obvious — a reader might expect Globe to declare this method directly.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and intended — Globe extends Extent, which declares getRadius(); EllipsoidalGlobe.getRadius() returns the equatorial radius, matching Earth.WGS84_EQUATORIAL_RADIUS used as the analyzer's default. No change needed.

Comment on lines +112 to +113
Angle angle = LatLon.greatCircleDistance(a, b);
return angle.radians * this.globeRadiusMeters;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Potential mismatch between analyzer's spherical distance model and rendered cylinder geometry

The ThreatCoverageAnalyzer computes exposure using great-circle distance on a sphere (LatLon.greatCircleDistance × globe radius), while CappedCylinder in addThreatVolume renders a cylinder on the actual ellipsoidal globe surface. For the threat radii in this scenario (120–370 km), the difference between spherical and ellipsoidal distance is small (< 0.3%), so assets near a ring boundary could theoretically show as exposed in the analysis but appear outside the rendered ring, or vice versa. For this example's purposes the discrepancy is negligible, but it's worth documenting if the analyzer is reused for higher-precision applications.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted — the spherical model is intentional and already documented in the class javadoc ("on a spherical Earth of the configured radius"), and the analyzer uses the globe's equatorial radius so exposure stays consistent with the rings. As you note the spherical-vs-ellipsoidal delta is <0.3% at these radii; leaving as-is for this example.

Comment on lines +112 to +156
try
{
// Load the scenario laydown from the bundled resource.
Scenario scenario = loadScenario();

// Draw threat rings first so symbols render on top of the translucent volumes.
for (Threat threat : scenario.threats)
{
this.addThreatVolume(threat);
}

// Draw a MIL-STD-2525 symbol for every entity in the picture.
for (Asset asset : scenario.assets)
{
this.addAssetSymbol(asset);
}
for (Threat threat : scenario.threats)
{
this.addThreatSymbol(threat);
}

// Compute protection coverage and flag exposed assets.
ThreatCoverageAnalyzer analyzer = new ThreatCoverageAnalyzer(
this.getWwd().getModel().getGlobe() != null
? this.getWwd().getModel().getGlobe().getRadius()
: ThreatCoverageAnalyzer.DEFAULT_GLOBE_RADIUS_METERS);
CoverageReport report = analyzer.analyze(scenario.assets, scenario.threats);

for (AssetExposure exposure : report.getExposures())
{
if (exposure.isExposed())
this.addExposureCallout(exposure);
}

// Print the coverage summary to the console and show it in the control panel.
String summary = report.formatSummary();
System.out.println(summary);
this.addCoveragePanel(scenario, report, summary);
}
catch (Exception e)
{
// WorldWind examples must not crash on startup. Log and continue with whatever loaded.
Logging.logger().log(Level.SEVERE,
Logging.getMessage("generic.ExceptionAttemptingToReadFile", SCENARIO_RESOURCE), e);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: File I/O occurs on the EDT during AppFrame construction

The loadScenario() call at line 115 performs file I/O (reading and parsing the CSV) synchronously in the AppFrame constructor, which runs on the Event Dispatch Thread. CONTRIBUTING.md's rule "Within a rendering pass WorldWind does not touch the disk or the network" applies to rendering passes specifically, not constructors, so this is technically compliant. For a small bundled CSV this is fine, but if the scenario file grows or is loaded from a network resource, this would block the UI thread. The existing pattern in other examples (e.g., TacticalSymbols.java) also does setup work in the constructor, so this is consistent with the codebase.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — this is a constructor, not a rendering pass, so it's compliant with the "no disk/network during a rendering pass" rule, and it matches the setup-in-constructor pattern used by TacticalSymbols/TacticalGraphics. The scenario is a tiny bundled CSV. Leaving as-is.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant